← szzt · All docs

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:


1. Request demultiplexing — the host names the site

Normative

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.


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:

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

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

As-built

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

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

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

As-built

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:

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:


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


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

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.)

← All docs