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:
- 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. - 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 ofslug,siteOrigin(a reversible<siteRkey>--<did-suffix>label decoded byparseSiteOriginLabel),domain,version(a root-CID host, which setspinned = true), orapex. The bareapexnames 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), andTILE_PREFIX(/.well-known/minisite/tile/). The header comment onTILE_PREFIXrecords the H16 decision that a bare/tile/…or<site>/export.zipwould 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_PREFIXbefore host demux and forcesmode = "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):
- every block's bytes hash to the CID it is filed under (
readCarWithRoot, with theskipCidVerificationescape hatch deliberately not passed); - the CAR root parses as a v3 commit for the requested DID;
- the commit signature verifies under the expected signing key (
verifyCommitSig) — "THE check the read path was missing (§10.2)", the reason the file exists; - the MST path from the signed
commit.dataroot resolves to the leaf; - 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:webhost, 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 viafetchFollowingRedirects()againstisPublicHttpsUrlwith 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).firstPrivateAddressis 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 (createMultiPdsReader →
atpReader({ 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-Forhop (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()refusescid-shape(order charset → length → cid-shape → reserved) andSlugRegistryretains skeletons acrossrelease(). Wired viaSqliteNameStore.allocateSlug(), endpoint gated onSERVICE_DID+NAME_DB_PATHincreateServerFromEnv()(slug = { auth: authSlug, limiter, allocator: store }). - Custom domains: the HTTP bind route is
packages/server/src/http.tsatDOMAIN_BIND_PATH(handleDomainBind), wired asopts.domainfromcreateServerFromEnv()(authDomain+DnsTxtResolver), which DNS-fetches and verifies the_drop.<domain>proof before writing (a). The re-check loop isstartDomainRecheck()/runDomainRecheckLoop()(domain-recheck.ts), started at the entry point over the shared name-store handle, interval fromresolveDomainRecheckIntervalMs()(default 5 min); a definitive proof-gone drops the binding, a transient DNS failure leaves it untouched (b). TLS revoke-on-drop threads the samecertStoreinto the re-check loop'sonOutcomes(c). - Credible exit:
packages/server/src/resolve.ts resolveSiteTarget()builds theat://URI with no index;@minisite/verifyships the independentminisite-verifyCLI, 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()setspinned = trueon aversionhost;dispatch.ts indexFor()treatsby === "rootCid"as immutable so its resolved index never expires, andhandleServe()keeps the immutablecache-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. Currentmain.ts+http.tswire the HTTP domain-bind endpoint, thestartDomainRecheckloop, 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 aLabelStoreover the trusted-DID allowlist, anEnforcer, and aLabelerSyncdriven byrunLabelerSyncover the realconnectLabelStreamWebSocket. Enabled by default toward the Bluesky mod service (BSKY_LABELER_DID/BSKY_LABELER_ENDPOINT); an operator opts out with emptyLABELER_ENDPOINT/LABELER_TRUSTED_DIDS(resolveLabelerConfig(), fail-closed trusted set). - Enforcement seam:
dispatch.ts syncGate()(503 untilModeration.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.tsthreadsEnforcer.didBlockedBy()asruntime.isDidBlockedintoSqliteNameStore, so a labeled DID's slug/domain writes are refused. - Reports (§17.3): the anonymous
/.well-known/minisite/reportendpoint is wired whenREVIEW_DB_PATHis 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_CURSORarms 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
isDidBlockedis 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 theisDidBlockedthreading 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 whenCERT_DB_PATHis 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 viaServiceAuthVerifier(createServerFromEnv()authFor()), each audience-bound toSERVICE_DIDandlxm-scoped withmaxLifetimeSeconds: 300. None of them writes to a PDS:notifyonly force-refreshes the pointer cache from re-read, re-verified signed state;slug/domainwrite the gateway's own SQLite naming index;pin/unpinwrite 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 guardedputRecordof thepub.mini.siterecord against the publisher's ownPdsClient, withswapRecord+swapCommitCAS guards. The publisher UI calls it viaapps/publisher/src/main.ts revertTo(), which passesatpClient(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.)