← szzt · All docs

Szzt — Architecture & Technology Inventory

Szzt publishes small static sites and loose files to a user's own atproto PDS, then serves them content-addressed and hash-verified at <name>.szzt.app. Because content lives in the user's PDS and every byte is checked against its content ID (CID), the gateway is a convenience and never an authority — the spec calls this credible exit.

Naming note. The product is "Szzt" and deploys at szzt.app, but the code is internally named Mini-Site: every workspace package is @minisite/*, the root package is minisite, and the normative spec is mini-site-spec.md at the repo root. "Szzt" and "Mini-Site" are the same system. Spec section references below (e.g. §9.2) point into mini-site-spec.md.

Repository layout

A pnpm workspace (pnpm-workspace.yaml, package.jsonpnpm@10.33.2, Node ≥ 20, ESM throughout). Two globs: packages/* (libraries) and apps/* (deployables). Every package is @minisite/*, "private": true, version 0.0.0, built with tsup (esbuild under the hood), typechecked with tsc --noEmit, and tested with vitest. tsc is typecheck-only — no package emits with it.

Authoritative references at the repo root: mini-site-spec.md (normative), conformance-audit.md, and roadmap.md. Several plan docs (per-site-origins-plan.md, h12-verifier-sw-plan.md, deploy-provenance-plan.md, moderation-labeler-research.md) capture specific subsystems.

apps/
  gateway/     Node HTTP server — the composition root; serves + resolves + verifies
  publisher/   Browser SPA — drag files, OAuth login, build, preview, publish
packages/
  core/        Deterministic build: files -> MASL manifest + root CID (dCBOR42/DASL)
  records/     atproto record shapes, AT-URI resolution, CID re-derivation (pure)
  publish/     Write path: blob-first upload + atomic swap-guarded PDS record write
  gateway/     PURE serving/verification engine + browser SW shell (transport injected)
  server/      Node HTTP/TLS/ACME glue wrapping the pure engine; naming; RASL mirror
  verify/      Reference verifier + CLI + the browser verifier service worker (sw.js)
  frontend/    Browser front-end kernels: ingest/normalize/preview + publish-flow FSM
  agent/       Headless publish facade (build -> verify root CID -> publish)
  moderation/  Label-based takedown enforcement + naming-allocation decision kernels

Dependency direction: core is the base (pure CID/build math); records depends only on core; gateway, publish, frontend, agent, moderation build on those; server wraps gateway; the two apps compose everything.


Applications

apps/gateway — the serving composition root

The deployed Node process and the wiring hub for the whole read path. Entry point apps/gateway/src/main.ts, symbol createServerFromEnv, reads env vars and assembles the runtime graph, returning an unstarted http.Server (or https.Server under the dormant app-TLS path). Highlights of what it wires:

Other app-local modules: sqlite-name-store.ts (the real §11 naming index — slug, custom-domain, and version-pinned resolution), dns-txt-resolver.ts (custom-domain proof lookups), and sqlite-cert-store.ts.

Build: tsup, platform: "node", ESM (apps/gateway/tsup.config.ts; note removeNodeProtocol: false so node:sqlite survives). Runs as node dist/main.js.

apps/publisher — the browser publisher SPA

A self-contained single-page app the gateway serves at the apex. Entry apps/publisher/src/main.ts — DOM + OAuth glue only, deliberately typecheck-only and never unit-tested; all load-bearing logic lives in the pure packages it drives: @minisite/core build(), @minisite/publish publish(), the publish-flow FSM (@minisite/frontend reduce/canPublish), and the drag/zip ingest (@minisite/frontend/browser). Login is fully in-browser atproto OAuth (@atproto/oauth-client-browser); the client_id is the served /client-metadata.json URL, so OAuth identity is bound to wherever the page is served. App-local helpers: derive.ts (config/format helpers, incl. mirror-config parsing), typeahead.ts (handle autocomplete), retention.ts (version pruning UI), slug.ts (slug allocation UI + service-auth), build/docs.ts (build-time Markdown→HTML rendering of the reference docs).

Build: tsup, platform: "browser", noExternal: [/.*/] — every dependency (atproto OAuth/api + the @minisite packages) is inlined into one dist/main.js. The onSuccess hook (apps/publisher/tsup.config.ts) copies the HTML shells (index.html, about.html) into dist/ and calls generateDocs() from apps/publisher/build/docs.ts, which renders the docs/*.md reference docs into styled HTML pages served at /publish/architecture.html, /publish/content-spec.html, /publish/gateway-conformance.html, and an index at /publish/docs.html. build/docs.ts uses marked and is build-time only (Node, reachable only from the tsup config) — src/main.ts never imports it, so it stays out of the browser bundle. A custom esbuild plugin (noNodeBuiltinsPlugin) fails the build if any Node builtin is pulled into the browser bundle — this guards against a real past outage where a stray fs import shipped a page that was dead on load.


Packages

packages/core — deterministic build & CID math

Turns files + metadata into a MASL manifest (path → blob CID map) and a root CID. src/cid.ts computes CIDs with @atcute/cbor (dCBOR42 / DRISL) and @atcute/cid (CIDv1 base32) — explicitly not @ipld/dag-cbor, which produces different bytes. This is the byte-exact CID root the whole system agrees on. Also: build.ts, masl.ts, synthesize.ts (fills in missing pieces like icons), normalize.ts, site-origin.ts, content sniffing. Pure/isomorphic; deps @atcute/cbor, @atcute/cid.

packages/records — atproto lexicons & resolution

The single source of truth for the three lexicon record shapes (src/types.ts, src/resolve.ts COLLECTION), so the write/verify/serve paths cannot drift:

NSID Role (spec)
pub.mini.site Stable site identity + active pointer (current strong-ref) — §4.1
pub.mini.deployment Immutable version; points at its manifest via tile — §4.2
ing.dasl.masl The inline MASL manifest record — §4.3

resolve.ts resolveTarget() follows site → deployment → manifest over an injected RepoReader seam and does no integrity checks itself (the caller re-derives CIDs). recompute.ts provides parseAtUri, recomputeRootCid, recomputeRecordCid, recomputeBlobCid. Pure; depends only on @minisite/core.

packages/publish — the write path

Blob-first upload then an atomic, swap-guarded record write to the publisher's PDS (src/publish.ts publish()PublishResult; src/rollback.ts; src/blobs.ts uploadBlobs with BlobCidMismatchError). src/client.ts (atpClient) is a thin @atproto/api adapter over a minimal PdsClient seam. Also holds the mirror producers (see Transports below): rasl-producer.ts (raslBucketProducer), r2-bucket-put.ts (Cloudflare R2), ipfs-producer.ts + kubo-block-put.ts (Kubo/IPFS block/put), plus retention/prune helpers. Node-targeted; deps @atproto/api, @atproto/common-web.

packages/gateway — the pure serving/verification engine

The transport-agnostic heart: builds the CID→location index from signed state, routes, and serves each resource by CID re-hashing every byte (fail closed). The network transport is an injected seam, so the same engine runs server-side and in the browser SW. Key symbols: src/serve.ts serve(); src/index-map.ts resolveIndex()MiniSiteIndex; src/verify-cache.ts VerifyingCache (+ BoundedBlobStore, MemoryBlobStore); src/mirror.ts MirrorFetcher (races mirror hints, output still re-verified); src/zip.ts assembleZip and src/bundle.ts assembleBundle (credible-exit export / transport bundle); src/types.ts GatewayError, VerificationError. It also owns the DID/PDS resolution + SSRF primitives (did-resolver.ts, pds-resolver.ts, did-cache.ts, ssrf.ts assertPublicHttps) and a browser SW shell (sw.ts registerServiceWorker, exported at ./sw; plus cache-storage.ts). Deps: only @minisite/core + @minisite/records — no external runtime deps. Pure barrel is isomorphic; browser pieces are separate entries.

packages/server — Node HTTP/TLS/ACME glue

Wraps the pure engine with everything socket-shaped. src/dispatch.ts dispatch() is the request orchestrator: it resolves the target AT-URI, builds the signed serving index, serves through the pure engine, applies §12 caching, and enforces §17.5 moderation — and never throws (every failure is a defined HTTP response; fail-closed 502 for integrity/upstream, 404 for naming misses, 451 for takedowns, 503 for the moderation readiness gate). src/http.ts is the Node socket/TLS glue (separate ./http entry, typecheck-only). Notable subsystems:

Deps: @atproto/crypto, cborg, undici, plus workspace core/gateway/records/ moderation. Node-targeted.

packages/verify — reference verifier + CLI + browser SW

The independent, trust-nothing verifier. src/car.ts verifyCarRecord() is the crux: given a com.atproto.sync.getRecord proof CAR plus the repo's expected signing key, it proves a record is really in the repo's signed tree — CID-checking every block, parsing the v3 commit, verifying its signature (the load-bearing step), walking the MST from the signed data root, and returning an unforgeable branded VerifiedRecord. There is no boolean/skip variant: an unprovable record has no return value. src/reader.ts atpReader is a RepoReader that runs every fetched record through verifyCarRecord; src/resolve-reader.ts adds the DID-doc hops. src/cli.ts ships the minisite-verify bin (inspect/diff/verify). src/sw-entry.ts is the browser verifier service worker: it marries readerForTarget → atpReader → verifyCarRecord with the gateway's registerServiceWorker, so after first hit the in-browser SW re-verifies every asset and the gateway is untrusted (§9.5, plan H12).

CBOR encoder boundary (load-bearing). Two CBOR encoders coexist here on purpose: @atproto/repo's dag-cbor owns everything repo-shaped (CAR blocks, MST nodes, re-encoding the unsigned commit for signature verification — because that is what the PDS actually signed), while @atcute/cbor (@minisite/core) owns the szzt-computed CIDs. car.ts never re-encodes a block to derive a CID, which is what keeps the two from ever having to agree. See the header comment in car.ts before touching it.

The SW ships as a self-contained classic sw.js IIFE — a second tsup/esbuild build (platform: "browser", noExternal) with custom plugins that guard against Node builtins, stub node:timers/promises (setImmediate → Promise.resolve), alias @atproto/common to a no-op logger, and inject the buffer polyfill. apps/gateway serves this bundle at /sw.js. Deps: @atproto/api, @atproto/repo, buffer; a playwright real-browser harness in dev.

packages/frontend — front-end kernels + publish FSM

Browser kernels for ingest → normalize → build → preview → publish, with the load-bearing logic pure and unit-tested. src/flow.ts reduce() is the explicit publish-flow state machine: publish is reachable only from the previewed state (a successful build and a rendered sandboxed preview) and only when authenticated (canPublish) — illegal transitions return the state unchanged, so impossible states are unrepresentable. Also ingest.ts (decompression-bomb ceiling guard), preview.ts (a verifying preview that reuses the gateway serve engine rather than a second verify path), exit-routes.ts (§11.4 credible-exit URLs). Framework-agnostic — no React/Vue/Svelte; DOM/OAuth glue is isolated in the typecheck-only ./browser entry.

packages/agent — headless publish facade

An ergonomic, non-browser API over @minisite/core + @minisite/publish: build → verify the root CID → publish (§15). src/build.ts buildSite, src/verify-build.ts verifyBuild (re-checks the root CID before writing), src/auth.ts resolveAuth, src/publish.ts publishSite/publishFiles. No external deps. Node-targeted.

packages/moderation — takedown enforcement + naming policy

Pure decision kernels over injected DNS/labeler seams; never in the write or auth path. src/labels.ts LabelStore (ingests com.atproto.label labels, answers "what applies to this target"), src/enforce.ts Enforcer (delist-only takedown decisions), src/labeler-sync.ts LabelerSync (an explicit connection/readiness FSM: connecting | backfilling | synced | reconnecting) + runLabelerSync() (reconnect loop draining a label stream into a LabelStore). The actual com.atproto.label.subscribeLabels WebSocket lives in @minisite/server. Also slugs.ts / domains.ts / detection.ts / reports.ts. Depends only on @minisite/records.


Technology inventory

Concern Technology / where
Monorepo pnpm workspaces (pnpm-workspace.yaml), pnpm@10.33.2, Node ≥ 20, ESM
Build tsup (esbuild) per package; tsc --noEmit for typecheck; vitest for tests
Content addressing dCBOR42 / DRISL via @atcute/cbor + @atcute/cid (packages/core/src/cid.ts); DASL subset of IPFS
Records & identity atproto lexicons pub.mini.site / pub.mini.deployment / ing.dasl.masl; @atproto/api, @atproto/repo, @atproto/crypto
Storage User's own atproto PDS (blobs + signed records)
Auth (publish) In-browser atproto OAuth (@atproto/oauth-client-browser); service-auth JWTs (ServiceAuthVerifier)
Verification Signed-CAR proof (verify/src/car.ts verifyCarRecord); per-byte CID re-hash (gateway VerifyingCache)
Browser publisher tsup browser bundle, single inlined dist/main.js, no-Node-builtin build guard; build also renders reference docs to HTML via marked (build-time only)
Verifier in browser Classic-script service worker sw.js (IIFE), served at /sw.js (verify/src/sw-entry.ts)
Retrieval transports RASL over HTTP (/.well-known/rasl/<cid>); mirror hints via CDN/R2 and IPFS/Kubo; PDS as backstop
Server transport Node http/https, undici guarded fetch (SSRF-pinned), cborg
Custom-domain TLS ACME / RFC 8555 (server/src/acme.ts), WebCryptoAcmeSigner (P-256, PKCS#10), Let's Encrypt
Moderation atproto labelers (com.atproto.label), delist-not-erase Enforcer (packages/moderation)
Deploy One command: pnpm ship (scripts/deploy.mjsrailway up --ci, provenance stamp). Railway builds the image remotely from apps/gateway/Dockerfile (node:24-slim) — no local Docker

Retrieval transports (§13, "credible exit")

A resolved site can advertise mirror hints, and the gateway races them before falling back to the owner PDS — but a mirror is never trusted: MirrorFetcher re-hashes every mirror byte against the CID, so a hostile or stale mirror can only fail verification and be skipped. Two mirror producers exist on the write side (packages/publish): a Cloudflare R2 / CDN producer (r2BucketPut, raslBucketProducer) and an IPFS/Kubo producer (ipfsProducer, kuboBlockPut). The gateway itself can also act as a content-only RASL mirror (RaslMirror, gated on BLOB_DB_PATH).

Current state vs. spec (flag). The publisher's mirror-configuration UI (apps/publisher/index.html, the #r2-config and #ipfs-config sections) is currently hidden (class="hidden") — see the recent commit "hide CDN/IPFS mirror UI pending rework". The transports and their config parsing still exist in code, so the capability is present but not user-exposed right now.

Deployment & TLS posture

Deploying is one command from a clean tree: pnpm ship (= node scripts/deploy.mjs, which runs railway up --ci under the hood). scripts/deploy.mjs refuses a dirty tree, stamps apps/gateway/build-info.json so /healthz reports the exact commit (and whether it was pushed), uploads, then polls /healthz until the new commit is live. railway up uploads the local working tree, not a git ref, so git push ships nothing to production — the stamp is what keeps the running image traceable to a commit (see scripts/deploy.mjs and deploy-provenance-plan.md).

Nobody runs Docker locally. Railway builds the image remotely from apps/gateway/Dockerfile (node:24-slim): that build runs pnpm -r build, producing the gateway dist/, the publisher dist/ (SPA + client-metadata.json + generated reference-doc HTML pages), and the verifier sw.js — all served by the one gateway process. By default the gateway binds plain HTTP behind Railway's edge TLS; its own app-terminated TLS path (CUSTOM_DOMAIN_TLS, per-SNI certs from the cert store) is implemented but dormant / off by default pending the Railway raw-TLS topology (see the note in createServerFromEnv).


How a request flows (read path)

  1. A request arrives at apps/gateway main.ts's request listener. /healthz and the publisher SPA (on the apex) are handled first; everything else goes to the pure gateway handler.
  2. The handler parses the host/path (parseHost) into a GatewayRequest — a slug, custom domain, derived per-site origin, version-pinned root CID, a bare-apex /.well-known/rasl/<cid> mirror hit, an ACME challenge, or a bundle/export — and calls packages/server dispatch.ts dispatch().
  3. dispatch applies the §17.5 readiness gate (503 until the label stream is synced), then a per-IP read limiter, then resolves the target AT-URI to a signed serving index (resolveSiteTargetresolveIndex), following the signed hops site → deployment → manifest. Records are fetched through atpReader, so each is proven against the repo's signed commit (verifyCarRecord).
  4. Takedown enforcement (guardServe / guardCid) refuses a labeled DID/URI/CID with
  5. The pure @minisite/gateway engine (serve) fetches each resource blob — gateway cache → mirror hints (MirrorFetcher) → owner PDS — and re-hashes every byte against its CID (VerifyingCache). A mismatch is a fail-closed 502; it never serves unverified bytes.
  6. dispatch applies §12 caching (immutable for version-pinned origins, short pointer TTL for named/current origins) and returns a defined HTTP response — it never throws.
  7. On first hit, synthesized site content registers the verifier service worker (/sw.js); thereafter the in-browser SW re-verifies every asset, so the gateway is no longer trusted for integrity.

How a publish flows (write path)

  1. In the browser publisher (apps/publisher/main.ts), the user signs in via atproto OAuth, then drops files/folder/zip. Ingest runs through @minisite/frontend/browser (with a decompression-bomb ceiling guard).
  2. @minisite/core build() normalizes the files, synthesizes anything missing, hashes each file to a CID, and assembles the MASL manifest whose CID is the site's root CID.
  3. The publish-flow FSM (@minisite/frontend reduce) advances idle → building → ready. The site renders in a sandboxed preview iframe; only once it has rendered does the machine reach previewed. Publish is unreachable until previewed and authenticated (canPublish) — the §14 preview-before-publish gate encoded as transitions, not ad-hoc flags.
  4. On publish, @minisite/publish publish() uploads blobs first (each verified to its CID) and then does an atomic, swap-guarded write of the ing.dasl.masl, pub.mini.deployment, and pub.mini.site records to the user's PDS. Rollback is available if a swap guard trips.
  5. Optionally the publisher pushes the new version as a hint to the gateway (§10) and/or mirrors blobs to its own R2/IPFS (transports present but UI currently hidden). The headless @minisite/agent facade performs the same build → verify-root-CID → publish sequence without a browser.
  6. The user gets a URL (<slug>.szzt.app, a derived per-site origin, or a web+rasl://<rootCID> credible-exit link). The next read request resolves the new current pointer and serves it verified.

Notes, gaps, and things to verify

← All docs