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