asdfas
0

Configure Feed

Select the types of activity you want to include in your feed.

szzt / docs / gateway-conformance.md
27 kB

Szzt Gateway Conformance Specification + Implementation Guide#

A gateway is the read-side host that answers https://<name>.szzt.app/… (and bound custom domains): it resolves a request to a mini-site's signed atproto state, hash-verifies every byte it serves, and terminates the request. Each requirement below appears twice:

  1. As a normative spec clause (RFC-2119 MUST/SHOULD/MAY) an independent implementer could build a conforming gateway against, derived from mini-site-spec.md §9–§17 and the §18 conformance summary.
  2. As an as-built account of how this repository satisfies it.

Where the standing audit (conformance-audit.md) and the current code disagree, the divergence is flagged inline under AUDIT DELTA — the audit is a dated snapshot (last full sweep 2026-07-16 at 17c2b01) and several of its OPEN gaps have since been wired.

Conformance language#

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY are to be interpreted as in RFC 2119. A conforming gateway satisfies every MUST in §§1–8 below. The as-built notes describe one conforming implementation; they are not themselves normative on other implementations.

Two scope limits inherited from the spec and audit, stated once so nothing below overreads:

  • Verifying signatures on the read path evicts a malicious gateway or mirror from the trust base. It does not evict a malicious PDS: repo commits are signed by the PDS-held signing key, so the origin PDS remains in the trusted computing base (conformance-audit.md "The honest scope limit").
  • §9.3's PSL-listed apex is a SHOULD that §18 explicitly relaxes to "recommended, not required", so a gateway on a non-PSL apex (like szzt.app) is conforming.

1. Request demultiplexing — the host names the site#

Normative#

  • A gateway MUST derive the identity of the mini-site being served from the Host alone. It MUST NOT expose a path-based identity route (a /at/<handle>/… style path on a shared origin), because author JavaScript served there would run on the gateway's own origin beside publisher credentials (§11.3).
  • A gateway MUST classify each request into exactly one serving intent and MUST turn a malformed host or path into a defined HTTP error response, never an exception or a crash.
  • A gateway MUST reserve gateway-owned verbs under a namespace that cannot collide with a legal author resource path (the spec uses /.well-known/minisite/). A bare author path (e.g. /tile/logo.png, /export.zip) MUST be served as ordinary content, not shadowed by a gateway verb.
  • A gateway MAY offer per-request tile-mode selection; if it does, the selector MUST live in the reserved namespace and apply identically on every front-end.

As-built#

packages/server/src/request.ts parseRequest() is the pure demux: it reduces a RawRequest (host + path + navigation flag) to a GatewayRequest union and touches no signed state.

  • Host → site is parseHost() (packages/server/src/apex.ts), producing one of slug, siteOrigin (a reversible <siteRkey>--<did-suffix> label decoded by parseSiteOriginLabel), domain, version (a root-CID host, which sets pinned = true), or apex. The bare apex names no site — its only content route is the §13 mirror — enforcing the §11.3 ban on a path-based identity route.
  • Reserved verbs are constants: BUNDLE_PATH (/.well-known/minisite/bundle), EXPORT_PATH (/.well-known/minisite/export.zip), and TILE_PREFIX (/.well-known/minisite/tile/). The header comment on TILE_PREFIX records the H16 decision that a bare /tile/… or <site>/export.zip would shadow a real author file, so the reserved namespace is load-bearing, not cosmetic.
  • Malformed inputs become { kind: "invalid", status, message } (e.g. a bad ACME token → 400, an unparseable host → 404), never a throw.
  • Per-request tile selection strips TILE_PREFIX before host demux and forces mode = "tile", so one site-mode origin also answers tile requests, host-agnostically.

2. Serving intents — the dispatch switch#

Normative#

A gateway MUST resolve each classified request against signed state and MUST NOT throw: every failure — naming miss, malformed signed state, integrity failure, upstream unavailability — MUST become a defined HTTP response. Fail-closed status discipline:

  • A definitive naming/absence miss MUST be a 404 (the record was reached and authoritatively reported absent), distinct from a 502 upstream/integrity failure.
  • An integrity or root-CID mismatch MUST fail closed (5xx, cache-control: no-store), never serve the mismatched bytes.

As-built#

packages/server/src/dispatch.ts dispatch() is the async core; its header states "It never throws … All verification lives in the gateway engine; the server adds none." It switches over GatewayRequest.kind:

kind handler conforming behavior
invalid inline text() echo the demux-assigned status/message (400/404)
acme handleAcme() answer /.well-known/acme-challenge/<token> from the injected ChallengeStore (§11.2 HTTP-01); 404 when no challenge or no store
rasl handleRasl() site-scoped /.well-known/rasl/<cid> — resolve the site index, then serve the CID's bytes hash-verified via the cache
mirror handleMirrorRasl() bare-apex content-only mirror (§13): serve a pinned CID with no site context; a cold miss is 404, never a first fetch
bundle handleBundle() §9.8 transport bundle — every resource block framed into one response, each re-hashed
export handleExport() whole-site zip (credible exit); content-disposition: attachment
serve handleServe() ordinary path serve through the pure @minisite/gateway serve() engine

Fail-closed mapping is centralized in errorResponse(): RecordNotFoundError → 404; GatewayError (root-CID mismatch / unresolvable signed state) → 502 with x-minisite-verify: failed; any other reader/PDS failure → 502 with x-minisite-verify: unavailable. A per-CID hash failure is verifyFailed() → distinct 502, no-store, carrying path/expected/computed CID. The rasl path additionally re-checks isSafeHeaderValue() on the signed content-type before writing headers (a hostile value fails closed to 502 rather than crashing writeHead).


3. Hash-verify every served byte (fail-closed) — SECURITY INVARIANT#

Normative#

  • A gateway MUST derive every served byte's identity from the mini-site's signed repo state and MUST re-hash every block against the CID it was requested under before serving or caching it. A block whose bytes do not hash to its CID MUST fail closed.
  • A gateway MUST verify the repo's commit signature chain — not merely that CIDs are internally self-consistent — so that a malicious gateway or mirror cannot substitute a self-consistent but unsigned record. (This is stronger than §9.2's literal text, which only requires CID derivation; see the SPEC-vs-code note at the end.)
  • There MUST be no "skip verification" / "unknown key" / "trust this reader" bypass on the read path.

As-built#

packages/verify/src/car.ts verifyCarRecord() is the read-path verifier. Given the bytes of a com.atproto.sync.getRecord proof CAR plus the repo's expected signing key, it walks the whole chain and returns a record only if all five steps pass (else throws CarVerifyError):

  1. every block's bytes hash to the CID it is filed under (readCarWithRoot, with the skipCidVerification escape hatch deliberately not passed);
  2. the CAR root parses as a v3 commit for the requested DID;
  3. the commit signature verifies under the expected signing key (verifyCommitSig) — "THE check the read path was missing (§10.2)", the reason the file exists;
  4. the MST path from the signed commit.data root resolves to the leaf;
  5. the leaf names a record block the CAR actually carries and it decodes as a CBOR map.

Fail-closed is structural, not conventional. The VerifiedRecord brand (verifiedRecordBrand, a unique symbol) is deliberately unexported: outside car.ts there is no way to name the type, so no caller can construct, cast, or mock a VerifiedRecord — the only producer is verifyCarRecord(), and there is no boolean-returning variant or ok flag to forget to check. A function that accepts a VerifiedRecord cannot be handed an unverified one. signingKey is a REQUIRED input with no "skip" branch. Availability failures are not special-cased into a pass: "the server didn't give me the proof" and "the proof is bad" are the same rejection.

This reader is packages/verify/src/reader.ts atpReader(), wired unconditionally in apps/gateway/src/main.ts createServerFromEnv() via createMultiPdsReader — above and outside the if (env.SERVICE_DID) auth block — so the read path verifies whether or not publisher-auth is configured. On the serving side, @minisite/gateway's VerifyingCache re-hashes every blob before store.set(); packages/server/src/http.ts makeHandler() builds new VerifyingCache(opts.fetcher, opts.blobStore), and the encoder-boundary note in car.ts's header explains why a served CID is a property of the bytes on the wire, never of a re-encode.


4. SSRF-guarded egress — two fetch implementations on purpose — SECURITY INVARIANT#

Normative#

  • Every outbound request a gateway makes to an attacker-named host — a target's PDS endpoint (from its DID document), a did:web host, or a mirror hint from a site record — makes the gateway a potential confused deputy. The transport MUST refuse private/loopback/link-local/CGNAT targets.
  • The guard MUST cover the URL shape, every redirect hop (the platform's automatic redirect following would otherwise carry a vetted public URL to http://169.254.169.254/), and the address the hostname actually resolves to — and MUST pin the connection to the vetted address so the address checked is the address dialed (no DNS-rebinding gap between check and connect).

As-built#

Two implementations of one SafeFetch seam (packages/gateway/src/safe-fetch.ts), by design — its header enumerates them:

  • createSafeFetch() (packages/gateway/src/safe-fetch.ts) — browser-pure: URL shape + manual redirect re-validation via fetchFollowingRedirects() against isPublicHttpsUrl with a hop cap. All a browser/service-worker package can offer, since it cannot resolve hostnames.
  • createGuardedFetch() (packages/server/src/guarded-fetch.ts) — the Node transport: shape + redirects plus post-resolution address checking and IP pinning (roadmap H15). firstPrivateAddress is the total-function security kernel; the DNS hop and undici dispatcher are injected so the guard is unit-tested against fakes and opens no socket.

The single injection site is apps/gateway/src/main.ts createServerFromEnv(), which builds createGuardedFetch() once and threads it into all four attacker-influenced transports: the DID-document fetcher (createDidDocumentFetcher), the multi-PDS reader (createMultiPdsReaderatpReader({ fetch: guardedFetch })), the mirror race (createUrlFetcher(guardedFetch)), and the publisher-auth signing-key resolver. The header comment is explicit that it is built once (one undici connection pool, one policy site) and never as a module-level or lazy singleton.


5. Read-path rate limiting#

Normative#

  • A gateway SHOULD shed a caller flooding distinct unknown CIDs before paying the expensive site resolve, and MUST negative-cache unknown-CID misses so repeats are O(1). The two halves — negative cache and rate limit — are both required by §12.
  • A per-caller limiter MUST be keyed on a trustworthy client identifier. Behind a single trusted reverse proxy this is the right-most X-Forwarded-For hop (every earlier entry is client-forgeable).

As-built#

packages/server/src/dispatch.ts readShed() consumes one token from deps.readLimiter keyed by clientIp, charged on handleRasl, handleMirrorRasl, handleBundle, handleExport, and handleServe before the resolve. The key is packages/server/src/request.ts rightmostForwardedFor() (documented rationale: the edge appends the real peer last). The production TokenBucketLimiter (capacity: 120, refillPerSecond: 60) is built in apps/gateway/src/main.ts createServerFromEnv() (readLimiter), swept by an unref'd prune() interval. The negative cache is @minisite/gateway's NegativeCache (a ~10k-entry LRU with its own global token bucket); handleRasl short-circuits a per-(site, cid) known-unknown in O(1) via resolved.cache.isNegativeCached(site, req.cid) — scoped to the resolved site (roadmap H7) so a redeploy's new rootCid can't be masked by a stale global "absent" fact.

AUDIT DELTA (G3): the audit records G3 as FIXED (2026-07-11); current code matches. No delta.


6. Naming — slugs, custom domains, version-pinned, credible exit#

Normative#

  • Slugs (§11.1): an authoritative slug allocator MUST refuse CID-shaped labels so the slug and version-pinned (<rootCID>.<apex>) label spaces never collide, and MUST retain a released slug's confusable skeleton so it stays refused.
  • Custom domains (§11.2): a gateway that binds custom domains MUST (a) verify a _drop.<domain> TXT proof before binding, (b) periodically re-check that proof on a short interval and promptly invalidate a binding whose proof has definitively disappeared (the dangling-CNAME / domain-hijack control), and (c) if it terminates its own TLS, revoke/drop the certificate when the binding is invalidated.
  • Credible exit (§11.3): every mini-site MUST be reachable by an index-free at:// route and an independent verifier, so a publisher is never locked to this gateway.
  • Version-pinned (§11.5): a <rootCID> host MUST address immutable content whose resolved index never expires.

As-built#

  • Slugs: packages/moderation/src/slugs.ts validateSlug() refuses cid-shape (order charset → length → cid-shape → reserved) and SlugRegistry retains skeletons across release(). Wired via SqliteNameStore.allocateSlug(), endpoint gated on SERVICE_DID + NAME_DB_PATH in createServerFromEnv() (slug = { auth: authSlug, limiter, allocator: store }).
  • Custom domains: the HTTP bind route is packages/server/src/http.ts at DOMAIN_BIND_PATH (handleDomainBind), wired as opts.domain from createServerFromEnv() (authDomain + DnsTxtResolver), which DNS-fetches and verifies the _drop.<domain> proof before writing (a). The re-check loop is startDomainRecheck() / runDomainRecheckLoop() (domain-recheck.ts), started at the entry point over the shared name-store handle, interval from resolveDomainRecheckIntervalMs() (default 5 min); a definitive proof-gone drops the binding, a transient DNS failure leaves it untouched (b). TLS revoke-on-drop threads the same certStore into the re-check loop's onOutcomes (c).
  • Credible exit: packages/server/src/resolve.ts resolveSiteTarget() builds the at:// URI with no index; @minisite/verify ships the independent minisite-verify CLI, which resolves DID→PDS and DID→signing-key from the target's own DID document and verifies every record (H3's verify tail).
  • Version-pinned: request.ts parseRequest() sets pinned = true on a version host; dispatch.ts indexFor() treats by === "rootCid" as immutable so its resolved index never expires, and handleServe() keeps the immutable cache-control (never downgraded to the pointer TTL).

AUDIT DELTA (G9, Clause 8 §11.2): the audit marks §11.2 an outright FAIL — "TXT-recheck + cert revocation both unwired", the only caller of verifyDomainProof()/invalidateDomain() being the operator CLI. This is now stale. Current main.ts + http.ts wire the HTTP domain-bind endpoint, the startDomainRecheck loop, and the full ACME lifecycle (§8 below). The audit's own cross-reference notes item 10/14 were PARKED at audit time; they have since landed. A conforming reader should treat §11.2 as satisfied by the current code, not by the audit verdict.


7. Federation & moderation#

7a. Content-only RASL mirror (§13)#

Normative: a mirror MUST serve only CIDs it has pinned (a cold miss is 404, not a fetch), and the serve MUST carry zero trust — the consuming verifier re-hashes every byte, so a corrupt or hostile mirror can only fail verification. A gateway consuming a resolved site's hints MUST re-hash every mirror byte against the CID and fall back to the PDS on any failure.

As-built: dispatch.ts handleMirrorRasl() serves opaque, immutable, nosniff bytes from deps.mirror (a RaslMirror), returning 404 on an unpinned CID. Hint federation is indexFor() wrapping the cache in a per-site MirrorFetcher when deps.urlFetcher is set and the index has hints; main.ts wires urlFetcher: createUrlFetcher(guardedFetch) unconditionally, so mirror URLs are SSRF-guarded and a site with no hints short-circuits to the PDS.

7b. Moderation (§17)#

Normative: a gateway subscribed to a labeler MUST (§17.5) enforce takedown labels on serving, RASL, and cache paths — refusing a labeled DID / AT-URI / root CID / content CID — and MAY gate serving on the label stream being synced. Enforcement MUST be attributable (who labeled, what value). §17.4 is delist, not erase: the content MUST remain reachable via the PDS and credible-exit routes; erasure is a PDS-side action (§17.6), never a gateway one. §17.3: a gateway that accepts abuse reports MUST route them into a review path.

As-built:

  • Subscription + runtime: apps/gateway/src/main.ts startLabelerModeration() builds a LabelStore over the trusted-DID allowlist, an Enforcer, and a LabelerSync driven by runLabelerSync over the real connectLabelStream WebSocket. Enabled by default toward the Bluesky mod service (BSKY_LABELER_DID / BSKY_LABELER_ENDPOINT); an operator opts out with empty LABELER_ENDPOINT / LABELER_TRUSTED_DIDS (resolveLabelerConfig(), fail-closed trusted set).
  • Enforcement seam: dispatch.ts syncGate() (503 until Moderation.ready()), guardServe() (451 on DID/URI/root/any-resource-CID takedown — delist the surface if ANY block is labeled), guardCid() (451 on a labeled content CID for RASL/mirror). takenDown() returns 451 with the attributing labeler DID + label value in the body (CR/LF-safe: only the fixed reason enum goes in a header). The body explicitly states the content remains reachable via the PDS and credible-exit routes — delist, not erase.
  • Delist of further publishing (§17.4): main.ts threads Enforcer.didBlockedBy() as runtime.isDidBlocked into SqliteNameStore, so a labeled DID's slug/domain writes are refused.
  • Reports (§17.3): the anonymous /.well-known/minisite/report endpoint is wired when REVIEW_DB_PATH is set (report = { review: { queue: SqliteReviewQueue }, limiter }), per-IP rate-limited; child-safety/ncii reports are refused (422) at the boundary in the no-mandatory-reporter posture.
  • Strict vs. enforce-as-you-go: LABELER_HEAD_CURSOR arms strict sync-before-serve (gate holds through backfill); unset is the documented default (serve immediately, enforce each label as it arrives), not a degraded fallback.

AUDIT DELTA (G4, Clause 14 §17): the audit's headline verdict is FAIL — "kernel complete, entirely unwired; no labeler subscription, no report path, no enforcement", and §17.4's isDidBlocked is called "a permanent no-op in production." All of this is now stale. The subscription (startLabelerModeration), the enforcement gates (syncGate/guardServe/guardCid), the report endpoint, and the isDidBlocked threading are present in current code. This is the single largest audit-vs-code divergence; treat Clause 14 as substantially wired, not FAIL. (The §17.2 publish-time detection SHOULD remains a separate, publisher-side concern not covered by this gateway document.)


8. App-owned TLS / ACME lifecycle (§11.2c) — DORMANT by default#

Normative#

A gateway that terminates its own TLS for bound custom domains MUST obtain a certificate per bound domain, persist it across restarts (Let's Encrypt rate limits make persistence mandatory), renew before expiry, and drop a certificate when its binding is invalidated. A gateway that terminates TLS at an upstream edge MAY omit all of this.

As-built#

Entirely gated on CERT_DB_PATH (+ a name store) and default OFF: unset ⇒ the gateway binds a plain http.createServer() behind the Railway edge, byte-for-byte the production posture. When set, apps/gateway/src/main.ts (entry point, below createServerFromEnv) builds the ACME driver (obtainCertificate over fetchAcmeHttp + a WebCryptoAcmeSigner), persists issued certs in SqliteCertStore, runs startCertRenewal() (default 12 h cadence, 30-day renew-before), and drops a cert on binding invalidation via the shared re-check loop. CUSTOM_DOMAIN_TLS additionally arms an https.createServer with a per-SNI SNICallback reading runtime.certStore — this switch is dormant pending the Railway raw-TLS topology (the HTTP-01 :80 bootstrap listener is intentionally not yet wired; customDomainTlsEnabled() is default-off and rejects garbage values).

AUDIT DELTA (Clause 8 §11.2c): the audit states "no cert subsystem runs at all … the RFC 8555 stack … has zero callers in apps/gateway/src." Now stale — the stack is wired at the entry point as above, though the TLS listener remains deliberately dormant. Certificate obtain/renew/ revoke run when CERT_DB_PATH is set; only self-termination waits on topology.


9. Composition invariant — socket-free factory, sockets at the entry point#

This is an architectural conformance property of this implementation (not a spec MUST), but it is what makes the security invariants above testable.

apps/gateway/src/main.ts createServerFromEnv() is socket-free: it constructs the whole request pipeline but opens no socket and starts no background loop. Everything that dials the network — the §17.5 labeler subscription, the §11.2(b) DNS re-check loop, the §11.2(c) ACME driver and renewal loop, the durable SQLite name store — is built outside the factory at the real entry point (if (import.meta.url === …)) and injected via the ServerRuntime struct (moderation, isDidBlocked, store, certStore, challenges, certProvisioner). Consequences:

  • Tests call createServerFromEnv() and drive the entire pipeline against fakes with no real DNS, no socket, no timer — the same discipline the pure SSRF/verify kernels follow.
  • The guarded fetch, the DID-document cache, and the signing-key resolver are each built once in the factory and shared, so there is one connection pool and one policy site.
  • A last-resort process guard (unhandledRejection/uncaughtException → log and keep serving) lives only at the entry point, backstopping the multi-tenant invariant that one tenant's bad request must never crash the process (roadmap H2).

10. Credentials stay client-held — SECURITY INVARIANT#

Normative#

A gateway MUST NOT hold a publisher's PDS write credentials and MUST NOT write to any PDS. Publish, rollback, and prune are the publisher's own authenticated actions against their own repo; the gateway is a pure read/serve surface plus (optionally) credential-free naming and mirror-pin endpoints authenticated by short-lived, audience-bound service-auth JWTs — never by held write creds.

As-built#

  • The gateway never imports a PDS write client. Its only publisher-authenticated endpoints (notify, slug, domain, pin/unpin) verify a short-lived service-auth JWT via ServiceAuthVerifier (createServerFromEnv() authFor()), each audience-bound to SERVICE_DID and lxm-scoped with maxLifetimeSeconds: 300. None of them writes to a PDS: notify only force-refreshes the pointer cache from re-read, re-verified signed state; slug/domain write the gateway's own SQLite naming index; pin/unpin write the gateway's own blob store.
  • Publish and rollback happen browser-side in the publisher app. Rollback is packages/publish/src/rollback.ts rollback() — a guarded putRecord of the pub.mini.site record against the publisher's own PdsClient, with swapRecord + swapCommit CAS guards. The publisher UI calls it via apps/publisher/src/main.ts revertTo(), which passes atpClient(agent) — the browser's own OAuth agent (agent.did), never a gateway-held credential. The gateway is never in the write path.

Requirements the code satisfies but the SPEC under-specifies (flagged, not invented)#

  • §9.2 has no serve-path signature MUST. §9.2 requires only CID derivation from a signed record; it does not demand that the record was signed at all. This gateway verifies the commit signature chain anyway (§3 above), so it passes §9.2 on the merits — but a different conforming implementation could verify nothing and still pass §9.2 as written. conformance-audit.md "Spec defects" recommends §9.2 gain an explicit serve-path signature MUST to codify what this code does. §3 above states the stronger requirement as the intended bar; §9.2 read literally alone is weaker.

Summary of audit-vs-code divergences#

The standing audit was last fully swept 2026-07-16 (17c2b01); its OPEN gaps have since largely been closed. Where a reader might otherwise trust a stale FAIL/OPEN verdict:

Audit item Audit verdict (2026-07-16) Current code
G4 / Clause 14 §17 moderation FAIL — entirely unwired Wired: labeler subscription, syncGate/guardServe/guardCid, report endpoint, isDidBlocked (§7b)
G6 / Clause 9 §12 capacity eviction FAILS — unbounded MemoryBlobStore http.ts makeHandler() backs the cache with opts.blobStore; main.ts wires a byte-bounded BoundedBlobStore
G8 / Clause 11 §14 preview OPEN — preview stage entirely unwired Wired: apps/publisher/src/main.ts showPreview()/mountPreview(), publish gated on the sandboxed-iframe PREVIEW_OK (publisher-side)
G9 / Clause 8 §11.2 custom domains FAIL — TXT-recheck + cert revocation unwired Wired: handleDomainBind route, startDomainRecheck, ACME obtain/renew/revoke (§6, §8)

G3 (read-path rate limit), G5 (notify signature chain), G1/G2 (SSRF redirects, slug CID-shape) are recorded FIXED by the audit and match current code. §10.3 push coalesce (G7) is now confirmed PASS: packages/server/src/pointer-cache.ts PointerCache.refresh() routes through the private #coalesce(), sharing the same #inflight single-flight map as resolve(). (The 2026-07-19 audit refresh flipped G7 PARTIAL → PASS on this evidence.)