eleven / messenger Public

server+web+native: hashcash admission for the fetch service (off by default) (#1254)

fetchpow.go, home/pow.go's scheme kept in step: LCHAT_FETCH_POW_BITS arms
/api/unfurl + /api/gif behind proof of work — a 401 hands out a sealed
single-use challenge, one SHA-256 grind buys a sealed ~1h ticket, and every
request after rides (and is rate-bucketed by) that ticket. One solve per
client per hour, never per unfurl. Query-param transport so cross-origin
callers pay no CORS preflight (the fresh ticket returns in X-Fetch-Ticket,
exposed via Access-Control-Expose-Headers); /img stays ungated — its HMAC
signature is already the admission.

Solvers on both platforms: shell.js fetchServiceGET (async digests, UI
keeps breathing) and LChatAPI.fetchServiceGET (grind runs detached). Native
also gains the FetchService registry — LChatAPI is a value type built in
several places (ThreadStore's computed property, ChatFeedModel's own copy),
so the per-instance service URL and earned tickets live process-wide.

Also fixes the #1252 native build break this uncovered: ThreadStore.api is
a COMPUTED property, so the gates assignment onto it never compiled — the
latched URL now lives on the store and threads through api's construction.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b51c796c81a499dc3576db410df947c9a344559e parent 588bc03
7 files changed, +507 −37
  • docs/fetch.md +16 −15
  • fetch.go +88 −4
  • fetch_test.go +68 −0
  • fetchpow.go +173 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +99 −9
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +16 −2
  • web/static/shell.js +47 −7
diff --git a/docs/fetch.md b/docs/fetch.md
index ebddbe9..a3f7e44 100644
--- a/docs/fetch.md
+++ b/docs/fetch.md
@@ -41,7 +41,7 @@ instance. Moving the fetch to the shared service buys, in rough order of value:
dictionaries. The cache at rest reveals nothing a reader doesn't already
know the URL for. No request logs; the only log line is startup.
- **Never:** member content. Member photos/files are encrypted client-side
- (`processImage` + `encryptBytes`) and are categorically out of scope — the
+ (`processImage` + `encryptBytes`) and are categorically out of scope —
its remit is content already on the public web. Any future endpoint that
would touch member bytes is a design error; say no.
- **Not an open proxy:** `/img` serves only URLs the service itself HMAC-signed
@@ -52,14 +52,21 @@ instance. Moving the fetch to the shared service buys, in rough order of value:
re-validates the resolved IP via `guardDialControl` — same guard as the
instance, links, and push.
-## Abuse posture (v1: honest, modest)
-
-`/api/unfurl` and `/img` carry per-IP token buckets, which behind the router
-collapse into global buckets (no forwarded IPs — a privacy feature with a
-rate-limiting cost). That is acceptable while nothing points at it by
-default. Before the client flip makes it load-bearing, add **hashcash** (the
-cloud provisioner already ships the machinery) so anonymous callers pay in
-CPU instead of identity.
+## Abuse posture — hashcash admission (shipped, off by default)
+
+Set `LCHAT_FETCH_POW_BITS` (0/unset = off) and `/api/unfurl` + `/api/gif`
+demand proof of work — home's space-creation scheme (`fetchpow.go`, kept in
+step with `home/pow.go`): a 401 hands out a sealed single-use challenge,
+the client grinds SHA-256 to that many leading zero bits ONCE, the solved
+request is served and answered with a sealed ~1h **ticket**, and every call
+after rides (and is rate-bucketed by) the ticket — one solve per client per
+hour, never per unfurl. Admission travels in query params so cross-origin
+callers pay no CORS preflight; `/img` needs none (its HMAC signature is the
+admission). Both web (`fetchServiceGET`, shell.js) and native
+(`LChatAPI.fetchServiceGET` + the `FetchService` registry) solve and hold
+tickets. Ungated, the per-IP buckets remain — which behind the router
+collapse into global buckets (no forwarded IPs — a privacy feature): arm
+the gate before the shared service is load-bearing for the hosted fleet.
## Deployment
@@ -92,12 +99,6 @@ dims, or comes out bigger "shrunk" — polish never breaks a card. Public web
images only; member photos keep their client-side pipeline
(`processImage`), always.
-## Roadmap
-
-- **Hashcash** before the shared service is load-bearing/default-on for the
- hosted fleet (the provisioner's machinery; anonymous callers pay CPU, not
- identity).
-
## Giphy (`GET /api/gif`)
The authenticated-upstream sibling: search/trending fronted with the
diff --git a/fetch.go b/fetch.go
index 5f5a839..36b98b2 100644
--- a/fetch.go
+++ b/fetch.go
@@ -97,6 +97,10 @@ type fetchServer struct {
// The platform's Giphy API key (LCHAT_GIPHY_KEY). Empty disables /api/gif
// (404), and clients fall back to the space's bring-your-own-key path.
giphyKey string
+ // Hashcash admission (fetchpow.go); nil = off (the default). Its seal key
+ // is minted into the DB alongside the salt so restarts keep tickets valid.
+ pow *fetchPowGate
+ powSealKey []byte
}
func newFetchServer(dbPath string) (*fetchServer, error) {
@@ -145,9 +149,26 @@ func newFetchServer(dbPath string) (*fetchServer, error) {
if g.signKey, err = hex.DecodeString(keyHex); err != nil {
return nil, err
}
+ powHex, err := fetchConfigSecret(d, "pow_seal_key")
+ if err != nil {
+ return nil, err
+ }
+ if g.powSealKey, err = hex.DecodeString(powHex); err != nil {
+ return nil, err
+ }
return g, nil
}
+// armPow turns the hashcash gate on at the given difficulty (0 = off).
+func (g *fetchServer) armPow(bits int) error {
+ gate, err := newFetchPowGate(bits, g.powSealKey)
+ if err != nil {
+ return err
+ }
+ g.pow = gate
+ return nil
+}
+
// fetchConfigSecret returns the named config value, minting a random one on
// first boot — the same insert-if-absent pattern as the instance's
// previewSalt, so restarts and concurrent starts converge on one value.
@@ -167,9 +188,11 @@ func fetchConfigSecret(d *sql.DB, key string) (string, error) {
func (g *fetchServer) handler() http.Handler {
mux := http.NewServeMux()
- mux.HandleFunc("GET /api/unfurl", g.serveUnfurl)
+ // The two open endpoints sit behind the (optional) hashcash gate; /img
+ // needs none — its HMAC signature is already the admission.
+ mux.HandleFunc("GET /api/unfurl", g.powGated(g.serveUnfurl))
mux.HandleFunc("GET /img", g.serveImage)
- mux.HandleFunc("GET /api/gif", g.serveGifSearch)
+ mux.HandleFunc("GET /api/gif", g.powGated(g.serveGifSearch))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
@@ -187,10 +210,63 @@ func (g *fetchServer) handler() http.Handler {
// design — no cookies, no credentials, nothing origin-scoped to protect.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
+ // The pow gate hands fresh tickets back on a response header; without
+ // this, cross-origin JS can see the response but not that header.
+ w.Header().Set("Access-Control-Expose-Headers", "X-Fetch-Ticket")
mux.ServeHTTP(w, r)
})
}
+// powGated wraps an open endpoint in the hashcash gate (nil gate = pass).
+// Admission rides query params, not headers — a custom request header would
+// cost every cross-origin caller a CORS preflight round trip.
+//
+// - ?ticket= a sealed, unexpired ticket → in, rate-bucketed by the ticket
+// - ?pow=&nonce= a solved challenge → consumed, request served, and a
+// fresh ticket returned in X-Fetch-Ticket for the requests that follow
+// - neither → 401 {"pow":{"challenge","bits"}} — the mint rides the
+// rejection, so admission costs one round trip, not two
+func (g *fetchServer) powGated(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if g.pow == nil {
+ next(w, r)
+ return
+ }
+ q := r.URL.Query()
+ if t := q.Get("ticket"); t != "" && g.pow.ticketOK(t) {
+ next(w, r)
+ return
+ }
+ if c := q.Get("pow"); c != "" && g.pow.verifyAndConsume(c, q.Get("nonce")) == nil {
+ if t, err := g.pow.mintTicket(); err == nil {
+ w.Header().Set("X-Fetch-Ticket", t)
+ }
+ next(w, r)
+ return
+ }
+ ch, err := g.pow.mintChallenge()
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "pow mint failed")
+ return
+ }
+ writeJSON(w, http.StatusUnauthorized, map[string]any{
+ "pow": map[string]any{"challenge": ch, "bits": g.pow.bits},
+ })
+ }
+}
+
+// rateKey buckets a gated request by its ticket (each solved client gets its
+// own bucket); ungated requests keep the caller-IP key, with its documented
+// behind-the-router collapse.
+func (g *fetchServer) rateKey(r *http.Request) string {
+ if g.pow != nil {
+ if t := r.URL.Query().Get("ticket"); t != "" {
+ return "t:" + t
+ }
+ }
+ return fetchCallerKey(r)
+}
+
// fetchCallerKey is the rate-limit bucket key. Mirrors linkscmd.go — behind
// the router (unix socket, no forwarded IPs) this degrades to one shared
// bucket, which is the accepted posture for now (package doc).
@@ -206,7 +282,7 @@ func fetchCallerKey(r *http.Request) string {
// validation, same language handling, same stale-serve semantics — but the
// card's image URL is rewritten to the signed /img proxy before it leaves.
func (g *fetchServer) serveUnfurl(w http.ResponseWriter, r *http.Request) {
- if !g.unfurlLimiter.allow(fetchCallerKey(r)) {
+ if !g.unfurlLimiter.allow(g.rateKey(r)) {
httpError(w, http.StatusTooManyRequests, "slow down")
return
}
@@ -447,7 +523,7 @@ func (g *fetchServer) serveGifSearch(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusNotFound, "no gif upstream")
return
}
- if !g.gifLimiter.allow(fetchCallerKey(r)) {
+ if !g.gifLimiter.allow(g.rateKey(r)) {
httpError(w, http.StatusTooManyRequests, "slow down")
return
}
@@ -559,6 +635,14 @@ func runFetchRun(args []string) {
// The one env secret: the platform Giphy key (unit env file /data/fetch.env).
// Absent, /api/gif 404s and clients use the space's own key instead.
g.giphyKey = strings.TrimSpace(os.Getenv("LCHAT_GIPHY_KEY"))
+ // Hashcash difficulty (LCHAT_FETCH_POW_BITS, 0/unset = off): arm before
+ // pointing the hosted fleet here; leave off for dev and self-hosters.
+ if bits, _ := strconv.Atoi(strings.TrimSpace(os.Getenv("LCHAT_FETCH_POW_BITS"))); bits > 0 {
+ if err := g.armPow(bits); err != nil {
+ die("fetch: pow: %v", err)
+ }
+ log.Printf("fetch: hashcash admission armed (%d bits)", bits)
+ }
ln, addrDesc, err := listen(*addr, *socket)
if err != nil {
die("fetch: %v", err)
diff --git a/fetch_test.go b/fetch_test.go
index 2b111f3..b6dd9d7 100644
--- a/fetch_test.go
+++ b/fetch_test.go
@@ -369,3 +369,71 @@ func TestFetchImageProxyShrinks(t *testing.T) {
t.Fatal("small image should pass through byte-identical")
}
}
+
+// The hashcash gate: unadmitted requests get a challenge, a solved challenge
+// serves the request and mints a reusable ticket, tickets admit until expiry,
+// and a solved challenge can't be replayed for a second ticket.
+func TestFetchPowGate(t *testing.T) {
+ ts, g := newTestFetch(t)
+ if err := g.armPow(8); err != nil { // 8 bits: a few hundred hashes
+ t.Fatalf("armPow: %v", err)
+ }
+
+ allowPrivatePreviewHosts = true
+ t.Cleanup(func() { allowPrivatePreviewHosts = false })
+ site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`<head><meta property="og:title" content="Gated"></head>`))
+ }))
+ t.Cleanup(site.Close)
+ target := "/api/unfurl?url=" + url.QueryEscape(site.URL)
+
+ // No ticket, no pow → 401 with a challenge.
+ r, err := http.Get(ts.URL + target)
+ if err != nil {
+ t.Fatalf("gated: %v", err)
+ }
+ var gate struct {
+ Pow struct {
+ Challenge string `json:"challenge"`
+ Bits int `json:"bits"`
+ } `json:"pow"`
+ }
+ json.NewDecoder(r.Body).Decode(&gate)
+ r.Body.Close()
+ if r.StatusCode != http.StatusUnauthorized || gate.Pow.Challenge == "" || gate.Pow.Bits != 8 {
+ t.Fatalf("gate = %d %+v", r.StatusCode, gate)
+ }
+
+ // Solve → the request itself is served AND a ticket comes back.
+ nonce := fetchSolvePow(gate.Pow.Challenge, gate.Pow.Bits)
+ r, err = http.Get(ts.URL + target + "&pow=" + url.QueryEscape(gate.Pow.Challenge) + "&nonce=" + nonce)
+ if err != nil {
+ t.Fatalf("solved: %v", err)
+ }
+ ticket := r.Header.Get("X-Fetch-Ticket")
+ var pv LinkPreview
+ json.NewDecoder(r.Body).Decode(&pv)
+ r.Body.Close()
+ if r.StatusCode != http.StatusOK || !pv.OK || pv.Title != "Gated" || ticket == "" {
+ t.Fatalf("solved: status %d pv %+v ticket %q", r.StatusCode, pv, ticket)
+ }
+
+ // The ticket readmits; the spent challenge does not.
+ r, _ = http.Get(ts.URL + target + "&ticket=" + url.QueryEscape(ticket))
+ r.Body.Close()
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("ticket readmit: %d", r.StatusCode)
+ }
+ r, _ = http.Get(ts.URL + target + "&pow=" + url.QueryEscape(gate.Pow.Challenge) + "&nonce=" + nonce)
+ r.Body.Close()
+ if r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("replayed challenge: want 401, got %d", r.StatusCode)
+ }
+ // Garbage tickets bounce back to the gate.
+ r, _ = http.Get(ts.URL + target + "&ticket=garbage")
+ r.Body.Close()
+ if r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("garbage ticket: want 401, got %d", r.StatusCode)
+ }
+}
diff --git a/fetchpow.go b/fetchpow.go
new file mode 100644
index 0000000..bdb301a
--- /dev/null
+++ b/fetchpow.go
@@ -0,0 +1,173 @@
+// Hashcash admission for the fetch service (docs/fetch.md § Abuse posture).
+// The service is identity-free by design and, behind the router, can't even
+// tell callers apart by IP — so before it becomes load-bearing for the hosted
+// fleet, anonymous callers pay in CPU instead of identity: solve one
+// challenge, receive a sealed ~1h ticket, and every request rides (and is
+// rate-bucketed by) that ticket. One solve per client per hour, not per
+// request — an unfurl must stay instant.
+//
+// The scheme is home/pow.go's (space creation, #561), kept deliberately in
+// step: SHA-256(challenge ":" nonce) clearing `bits` leading zero bits,
+// sealed expiring single-use challenges, verification costing one hash. Off
+// by default (LCHAT_FETCH_POW_BITS unset/0) — self-hosters and dev setups
+// never see it.
+package main
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "strconv"
+ "sync"
+ "time"
+)
+
+const (
+ fetchPowChallengeWindow = 5 * time.Minute
+ fetchPowTicketWindow = time.Hour
+)
+
+// fetchPowGate is the admission gate; a nil *fetchPowGate means off.
+type fetchPowGate struct {
+ bits int
+ aead cipher.AEAD // sealed challenges + tickets; key minted into the service DB
+ mu sync.Mutex
+ used map[string]int64 // consumed challenge -> expiry (replay guard)
+}
+
+func newFetchPowGate(bits int, key []byte) (*fetchPowGate, error) {
+ if bits <= 0 {
+ return nil, nil
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ return &fetchPowGate{bits: bits, aead: aead, used: map[string]int64{}}, nil
+}
+
+// seal/open: base64url(iv||ciphertext), the crypto.js convention like the
+// preview cache's.
+func (g *fetchPowGate) seal(payload []byte) (string, error) {
+ iv := make([]byte, g.aead.NonceSize())
+ if _, err := rand.Read(iv); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(g.aead.Seal(iv, iv, payload, nil)), nil
+}
+
+func (g *fetchPowGate) open(blob string) ([]byte, bool) {
+ raw, err := base64.RawURLEncoding.DecodeString(blob)
+ if err != nil || len(raw) < g.aead.NonceSize() {
+ return nil, false
+ }
+ payload, err := g.aead.Open(nil, raw[:g.aead.NonceSize()], raw[g.aead.NonceSize():], nil)
+ if err != nil {
+ return nil, false
+ }
+ return payload, true
+}
+
+// mintChallenge issues one solvable challenge: seal(expiry || random).
+// Stateless until solved — only CONSUMED challenges take memory.
+func (g *fetchPowGate) mintChallenge() (string, error) {
+ return g.sealExpiring(time.Now().Add(fetchPowChallengeWindow))
+}
+
+func (g *fetchPowGate) sealExpiring(expiry time.Time) (string, error) {
+ payload := make([]byte, 8+16)
+ binary.BigEndian.PutUint64(payload, uint64(expiry.Unix()))
+ if _, err := rand.Read(payload[8:]); err != nil {
+ return "", err
+ }
+ return g.seal(payload)
+}
+
+// verifyAndConsume checks one solution: the challenge opens, hasn't expired
+// or been used, and the nonce clears the difficulty. A passing challenge is
+// consumed — one solve, one ticket.
+func (g *fetchPowGate) verifyAndConsume(challenge, nonce string) error {
+ payload, ok := g.open(challenge)
+ if !ok || len(payload) != 24 {
+ return fmt.Errorf("bad challenge")
+ }
+ expiry := int64(binary.BigEndian.Uint64(payload))
+ if time.Now().Unix() > expiry {
+ return fmt.Errorf("challenge expired")
+ }
+ if !fetchPowSolves(challenge, nonce, g.bits) {
+ return fmt.Errorf("proof of work does not check out")
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ now := time.Now().Unix()
+ for c, exp := range g.used { // expire the replay guard as we go
+ if exp < now {
+ delete(g.used, c)
+ }
+ }
+ if _, replayed := g.used[challenge]; replayed {
+ return fmt.Errorf("challenge already used")
+ }
+ g.used[challenge] = expiry
+ return nil
+}
+
+// mintTicket issues the post-solve pass: same sealed shape, longer window,
+// no replay guard (a ticket is MEANT to be reused; the per-ticket rate
+// bucket is what bounds it).
+func (g *fetchPowGate) mintTicket() (string, error) {
+ return g.sealExpiring(time.Now().Add(fetchPowTicketWindow))
+}
+
+func (g *fetchPowGate) ticketOK(t string) bool {
+ payload, ok := g.open(t)
+ if !ok || len(payload) != 24 {
+ return false
+ }
+ return time.Now().Unix() <= int64(binary.BigEndian.Uint64(payload))
+}
+
+// fetchPowSolves / fetchLeadingZeroBits / fetchSolvePow mirror home/pow.go
+// (same package-main symbols exist there, unexported in that sibling binary's
+// package — keep the two in step).
+func fetchPowSolves(challenge, nonce string, bits int) bool {
+ h := sha256.Sum256([]byte(challenge + ":" + nonce))
+ return fetchLeadingZeroBits(h[:]) >= bits
+}
+
+func fetchLeadingZeroBits(h []byte) int {
+ bits := 0
+ for _, b := range h {
+ if b == 0 {
+ bits += 8
+ continue
+ }
+ for mask := byte(0x80); mask != 0; mask >>= 1 {
+ if b&mask != 0 {
+ return bits
+ }
+ bits++
+ }
+ }
+ return bits
+}
+
+// fetchSolvePow grinds nonces until one clears the difficulty — tests (at low
+// bits); the web and native clients carry their own solver loops.
+func fetchSolvePow(challenge string, bits int) string {
+ for n := 0; ; n++ {
+ nonce := strconv.Itoa(n)
+ if fetchPowSolves(challenge, nonce, bits) {
+ return nonce
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 6128800..de0f5be 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -1,3 +1,4 @@
+import CryptoKit
import Foundation
// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; the
@@ -391,15 +392,17 @@ public struct LChatAPI {
let ok: Bool?
}
var r: Resp?
- if !fetchURL.isEmpty,
- var c = URLComponents(string: fetchURL + "/api/unfurl") {
- c.queryItems = [URLQueryItem(name: "url", value: url.absoluteString)]
- if let u = c.url, let (data, resp) = try? await session.data(from: u),
- (resp as? HTTPURLResponse)?.statusCode == 200 {
- // A decoded answer — even ok:false — is real (the page offered
- // nothing); only transport failures fall through to the instance.
- r = try? JSONDecoder().decode(Resp.self, from: data)
- }
+ // The explicit field (tests) wins; otherwise the registry entry the
+ // store latched from FeatureGates — every LChatAPI copy for this
+ // instance routes alike, however it was constructed.
+ let svc = !fetchURL.isEmpty ? fetchURL
+ : FetchService.shared.url(forInstanceHost: baseURL.host ?? "")
+ if !svc.isEmpty,
+ let query = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics),
+ let data = await fetchServiceGET(svc, "/api/unfurl?url=" + query) {
+ // A decoded answer — even ok:false — is real (the page offered
+ // nothing); only transport failures fall through to the instance.
+ r = try? JSONDecoder().decode(Resp.self, from: data)
}
if r == nil {
let items = [
@@ -413,6 +416,64 @@ public struct LChatAPI {
description: r.description ?? "", imageURL: URL(string: r.image ?? ""))
}
+ /// One GET against the shared fetch service (docs/fetch.md), handling its
+ /// optional hashcash admission: a 401 carries {pow:{challenge,bits}} —
+ /// grind SHA-256(challenge ":" nonce) to `bits` leading zeros off-actor,
+ /// retry with the solution, and keep the returned ticket (process-wide,
+ /// FetchTickets) so it's one solve per ~hour, not per request. Query
+ /// params, matching the web client. nil = transport failure or non-200,
+ /// which callers treat as "fall back to the instance".
+ func fetchServiceGET(_ base: String, _ path: String) async -> Data? {
+ let sep = path.contains("?") ? "&" : "?"
+ func get(_ extra: String) async -> (Data, HTTPURLResponse)? {
+ guard let u = URL(string: base + path + extra),
+ let (data, resp) = try? await session.data(from: u),
+ let http = resp as? HTTPURLResponse else { return nil }
+ if let t = http.value(forHTTPHeaderField: "X-Fetch-Ticket"), !t.isEmpty {
+ FetchService.shared.setTicket(t, for: base)
+ }
+ return (data, http)
+ }
+ let ticket = FetchService.shared.ticket(for: base)
+ let extra = ticket.isEmpty ? "" :
+ sep + "ticket=" + (ticket.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "")
+ guard var (data, http) = await get(extra) else { return nil }
+ if http.statusCode == 401 {
+ struct Gate: Decodable {
+ struct Pow: Decodable { let challenge: String; let bits: Int }
+ let pow: Pow?
+ }
+ guard let pow = (try? JSONDecoder().decode(Gate.self, from: data))?.pow else { return nil }
+ let nonce = await Task.detached(priority: .userInitiated) {
+ var n = 0
+ while !LChatAPI.powSolves(pow.challenge, String(n), pow.bits) { n += 1 }
+ return n
+ }.value
+ let solved = sep + "pow=" +
+ (pow.challenge.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "") +
+ "&nonce=\(nonce)"
+ guard let again = await get(solved) else { return nil }
+ (data, http) = again
+ }
+ return http.statusCode == 200 ? data : nil
+ }
+
+ /// SHA-256(challenge ":" nonce) clears `bits` leading zero bits — the
+ /// same check as the server's (fetchpow.go) and home's space-creation gate.
+ static func powSolves(_ challenge: String, _ nonce: String, _ bits: Int) -> Bool {
+ var count = 0
+ for b in SHA256.hash(data: Data((challenge + ":" + nonce).utf8)) {
+ if b == 0 { count += 8; continue }
+ var mask: UInt8 = 0x80
+ while mask != 0 {
+ if b & mask != 0 { return count >= bits }
+ count += 1
+ mask >>= 1
+ }
+ }
+ return count >= bits
+ }
+
/// The instance's display name, or "" when it was never named, so callers
/// fall back to the host instead of parroting the compiled-in default.
/// The server's `name_set` flag is the authority (an admin-chosen name —
@@ -781,3 +842,32 @@ public struct LChatAPI {
return resp != nil
}
}
+
+/// Process-wide fetch-service state: LChatAPI is a value type constructed
+/// freely all over (ThreadStore's computed property, ChatFeedModel's own
+/// copy, the share extension), so the per-instance service URL learned from
+/// FeatureGates — and the hashcash ticket earned against a service — live
+/// here, keyed by instance host / service base respectively.
+final class FetchService: @unchecked Sendable {
+ static let shared = FetchService()
+ private let lock = NSLock()
+ private var urlByInstanceHost: [String: String] = [:]
+ private var ticketByBase: [String: String] = [:]
+
+ func url(forInstanceHost host: String) -> String {
+ lock.lock(); defer { lock.unlock() }
+ return urlByInstanceHost[host] ?? ""
+ }
+ func setURL(_ url: String, forInstanceHost host: String) {
+ lock.lock(); defer { lock.unlock() }
+ urlByInstanceHost[host] = url
+ }
+ func ticket(for base: String) -> String {
+ lock.lock(); defer { lock.unlock() }
+ return ticketByBase[base] ?? ""
+ }
+ func setTicket(_ ticket: String, for base: String) {
+ lock.lock(); defer { lock.unlock() }
+ ticketByBase[base] = ticket
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index afadeb0..3c157e6 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -63,9 +63,16 @@ public final class ThreadStore: ObservableObject {
// shape, replayed over a fresh server read on every save.
private var mutedOps: [String: Bool] = [:]
+ // The fetch service this instance prefers for unfurls (docs/fetch.md),
+ // latched from FeatureGates on refresh. Lives here because `api` below is
+ // computed fresh per access — state set on one copy would evaporate.
+ private var fetchServiceURL = ""
+
private var api: LChatAPI? {
guard let base = instance.baseURL else { return nil }
- return LChatAPI(baseURL: base, token: instance.deviceToken)
+ var a = LChatAPI(baseURL: base, token: instance.deviceToken)
+ a.fetchURL = fetchServiceURL
+ return a
}
public init(instance: SavedInstance) {
@@ -184,7 +191,14 @@ public final class ThreadStore: ObservableObject {
// Route unfurls through the instance's preferred fetch service. nil =
// the gates read failed — keep the routing we hold (a blip must not
// reroute); "" is a real answer (the instance says unfurl locally).
- if let fetchURL = gates.fetchURL { api.fetchURL = fetchURL }
+ // The registry copy is what reaches LChatAPI values built elsewhere
+ // (ChatFeedModel constructs its own — see FetchService).
+ if let fetchURL = gates.fetchURL {
+ fetchServiceURL = fetchURL
+ if let host = instance.baseURL?.host {
+ FetchService.shared.setURL(fetchURL, forInstanceHost: host)
+ }
+ }
// nil = fetch failed — keep the held set rather than "unblocking"
// everyone over a network blip.
if let blocked = await blocksAsync { blockedPeople = Set(blocked) }
diff --git a/web/static/shell.js b/web/static/shell.js
index 6cd0631..73b86f2 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -4216,6 +4216,49 @@ async function awaitConnectResult(threadId, key, rid, tries = 24) {
let oauthPending = null; // { service, state }
const oauthCfgCache = {}; // service -> { client_id, authorize_url, redirect_uri }
+// ---- fetch-service doorway (docs/fetch.md) --------------------------------
+// One GET against the shared fetch service, handling its optional hashcash
+// admission: a 401 carries {pow:{challenge,bits}} — grind SHA-256(challenge
+// ":" nonce) to `bits` leading zeros (async digests yield, so the UI keeps
+// breathing), retry with the solution, and keep the returned ticket for every
+// call after (one solve per ~hour, not per request). Query params, not
+// headers — a custom header would cost a CORS preflight per call.
+let fetchServiceTicket = "";
+
+function fsLeadingZeroBits(bytes) {
+ let bits = 0;
+ for (const b of bytes) {
+ if (b === 0) { bits += 8; continue; }
+ for (let mask = 128; mask; mask >>= 1) {
+ if (b & mask) return bits;
+ bits++;
+ }
+ }
+ return bits;
+}
+
+async function fetchServiceGET(path) {
+ const base = window.__fetchURL;
+ if (!base) return null;
+ const sep = path.includes("?") ? "&" : "?";
+ const get = (extra) => fetch(base + path + extra, { signal: AbortSignal.timeout(15000) });
+ let r = await get(fetchServiceTicket ? `${sep}ticket=${encodeURIComponent(fetchServiceTicket)}` : "");
+ if (r.status === 401) {
+ const c = ((await r.json().catch(() => ({}))).pow) || null;
+ if (!c || !c.challenge) return r;
+ const enc = new TextEncoder();
+ for (let n = 0; ; n++) {
+ const d = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(`${c.challenge}:${n}`)));
+ if (fsLeadingZeroBits(d) >= c.bits) {
+ r = await get(`${sep}pow=${encodeURIComponent(c.challenge)}&nonce=${n}`);
+ fetchServiceTicket = r.headers.get("X-Fetch-Ticket") || fetchServiceTicket;
+ break;
+ }
+ }
+ }
+ return r;
+}
+
let oauthCompleting = false; // true while a returned code is being exchanged (drives the "Connecting…" state)
// Fetch + cache the broker's public OAuth config. The connect surface warms this
@@ -4733,11 +4776,8 @@ setHostBridge({
// bring-your-own-key path below.
if (window.__fetchURL) {
try {
- const r = await fetch(
- `${window.__fetchURL}/api/gif?q=${encodeURIComponent(q || "")}&limit=${limit}`,
- { signal: AbortSignal.timeout(15000) },
- );
- if (r.ok) return (await r.json()).data || [];
+ const r = await fetchServiceGET(`/api/gif?q=${encodeURIComponent(q || "")}&limit=${limit}`);
+ if (r && r.ok) return (await r.json()).data || [];
} catch (e) {
console.error("gif search via fetch service failed:", e);
}
@@ -4953,8 +4993,8 @@ setChatAppHost({
// a reason to re-ask the instance).
if (window.__fetchURL) {
try {
- const r = await fetch(`${window.__fetchURL}/api/unfurl?url=${encodeURIComponent(url)}`);
- if (r.ok) return await r.json();
+ const r = await fetchServiceGET(`/api/unfurl?url=${encodeURIComponent(url)}`);
+ if (r && r.ok) return await r.json();
} catch {}
}
try {