← 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. Spec section references below (e.g. §9.2) point into szzt-spec.md at the repo root. ing.dasl.masl keeps its own NSID because it is DASL's upstream Web Tiles record adopted as-is; every szzt-owned identifier is app.szzt.* or szzt.

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 @szzt/*, "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: szzt-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)
  mcp/         stdio MCP server over the agent facade — publish/republish/read/name a site
  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; mcp wraps agent; 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: @szzt/core build(), @szzt/publish publish(), the publish-flow FSM (@szzt/frontend reduce/canPublish), and the drag/zip ingest (@szzt/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 @szzt packages) is inlined into one dist/main.js. The onSuccess hook (apps/publisher/tsup.config.ts) copies the HTML shells (index.html, about.html) and the shared stylesheet (styles.css) 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 (the standalone pages about and docs are canonical without the extension; publisher-assets.ts serves those and 301s the .html form to them). 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.

Styling has one owner: apps/publisher/styles.css. The app shell, the About page, and every generated docs page link it; no page carries a <style> block or a style= attribute. Rules that legitimately differ per page are scoped by a body class — about on the About page, doc on the generated docs pages — rather than duplicated, so the main max-width, h1, h2, code, and link-colour differences between the three surfaces live in one file.


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)
app.szzt.site Stable site identity + active pointer (current strong-ref) — §4.1
app.szzt.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 @szzt/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. It also owns the gateway credential: src/service-auth.ts ServiceAuthMinter (a one-method seam) and atpServiceAuthMinter, which mints a §10.1 service-auth JWT via com.atproto.server.getServiceAuth — scoped to one aud + lxm, and mintable from an ordinary app-password session, so no OAuth is needed to authenticate to a gateway. loginSessionWithAppPassword returns a PdsSession ({ client, minter }) from one login; loginWithAppPassword is defined in terms of it and keeps returning just the client. Minting lives here because this is the only package that depends on @atproto/api — a caller that speaks to a gateway takes the seam instead. 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()SzztIndex; 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 @szzt/core + @szzt/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 szzt-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 (@szzt/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 + the szzt CLI

An ergonomic, non-browser API over @szzt/core + @szzt/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 (and loginSession, one login yielding both the PdsClient and a ServiceAuthMinter), src/publish.ts publishSite/publishFiles. No external deps. Node-targeted.

Also ships the szzt bin (src/cli.ts, szzt publish <dir> and szzt slug <name>) — the surface a headless agent drives. Supporting modules: src/files.ts readDirFiles (directory tree → FileInput[]; POSIX-separated relative paths so the same tree yields the same root CID on any OS, and .git/node_modules/symlinks skipped so nothing outside the publish root can be published), src/urls.ts siteUrls (the derived addresses only — per-site origin and web+rasl:// exit link; an allocated slug or custom domain is a separate authenticated request and is never inferred), and src/cli-args.ts (the unit-tested pure half: parseArgs, credentialsFromEnv, metaFromFlags, usage).

Slug claims (§11.1) are the one place this package talks to a gateway, so that a headless publisher can hold a real name (https://<slug>.<apex>/) and not only a derived origin — the only address a did:web author has at all. src/slug.ts is the pure half (slugCommandArgs, normalizeSlug, slugInputError, slugOutcomeError, slugSiteUrl, slugAllocUrl, serviceDidFor, plus SLUG_ALLOC_PATH/SLUG_ALLOC_LXM mirrored from @szzt/server slug-alloc.ts, as apps/publisher/src/slug.ts mirrors them — no dependency exists between the three, and a drifted lxm is a 401). src/claim-slug.ts claimSlug mints a token through the ServiceAuthMinter seam and POSTs the allocation over plain fetch (injectable; no HTTP dependency), raising SlugClaimError — with the gateway's HTTP status when it answered, and without one when it could not be reached. A claim grants no authority over content: the gateway still re-derives and hash-verifies every byte from the author's repo. Only allocation is exposed; release permanently RETIRES a slug, which is not a thing a CLI flag should put one keystroke away. szzt publish --slug <name> runs both steps on one login, validating the name before the build so a typo costs no publish, and — because the write has already landed by then — reports a failed claim as exactly that: the deployment stands, its printed URLs work, and only the name is unclaimed (exit non-zero).

Credentials reach the CLI only through the environment — SZZT_IDENTIFIER, SZZT_APP_PASSWORD, optional SZZT_PDS — never argv, which leaks into shell history, ps, and agent transcripts. The gateway is not in this path: build is local, the root CID is re-verified before any write, and the write goes straight to the author's PDS, so agent-published content lives in the author's own repo and carries the same credible exit as a browser publish.

packages/mcp — the stdio MCP server

The same headless publish path as the CLI, exposed to a model as tools. A stdio server (src/bin.ts, bin szzt-mcp) built on @modelcontextprotocol/sdk, registering four tools in src/server.ts: publish_site (new site), republish_site (replace content, keep the URL — the only tool that does), get_site (read-only), and allocate_slug (claim a readable <slug>.<apex> name). Depends on @szzt/agent, which is where the build/verify/ write actually happens.

Three of the four never touch a gateway — build is local, the write goes to the operator's PDS. allocate_slug is the exception by definition, since a slug is an entry in one gateway's naming index (tools.ts allocateSlugTool over @szzt/agent claimSlug). It needs a session rather than a client, because the claim is authenticated with a service-auth JWT that only a login can mint — hence the second seam, ToolDeps.session, and SlugSessionUnavailableError when it is absent. The slug is normalized and charset-checked before the request: a claim is permanent, so a typo must fail locally and for free.

Layered so the behaviour is testable without a transport: src/inputs.ts is the pure input kernel (resolveSource — exactly one of dir / files; inlineFilesToFileInputs; metaFromInput), src/tools.ts holds the tool bodies (publishTool, siteStatusTool) with the credential seam injected as ToolDeps, src/result.ts shapes what comes back, and src/server.ts is protocol wiring only. Tests drive the tool bodies end to end against the in-memory PdsClient in test/fixtures.ts — no network, no transport.

Inline base64 is round-trip checked in inlineBytes because Node's decoder silently drops out-of-alphabet input, which would publish a corrupt file under a root CID that commits to the corruption. Path shape, ceilings and entry inference are deliberately not re-checked here — @szzt/core normalize() owns those rules, and forking them would let the two publish paths diverge.

Credentials are read from the server's own process environment (src/env.ts credentialsFromEnv, mirroring the CLI's three variables) and are never tool parameters: a tool parameter is written by the calling model and echoed back in the transcript, so exposing one would let the model supply, read back, or exfiltrate the operator's app password. The seam is a function resolved per call, so a missing variable is a readable tool error naming it rather than a launch-time death behind an opaque transport failure. An MCP client configures it like this:

{
  "mcpServers": {
    "szzt": {
      "command": "node",
      "args": ["/abs/path/to/szzt/packages/mcp/dist/bin.js"],
      "env": {
        "SZZT_IDENTIFIER": "you.example.com",
        "SZZT_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx",
        "SZZT_PDS": "https://bsky.social"
      }
    }
  }
}

The absolute path to the built file is what a client launches: every workspace package is "private": true, so the szzt-mcp bin the package declares is only on PATH inside the workspace, and an MCP client starts the server from its own working directory. Run pnpm -r build first — dist/ is gitignored.

SZZT_PDS is optional and defaults to https://bsky.social. SZZT_APP_PASSWORD is an app password, never the account password — an app password is separately revocable and cannot change the account's own credentials. Every publish is a real, public, externally visible write with no unpublish, which is why the server's instructions and each tool description say so: the model is the one deciding when to call, and it needs that in front of it.

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 @szzt/server. Also slugs.ts / domains.ts / detection.ts / reports.ts. Depends only on @szzt/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 app.szzt.site / app.szzt.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 @szzt/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 @szzt/frontend/browser (with a decompression-bomb ceiling guard).
  2. @szzt/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 (@szzt/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, @szzt/publish publish() uploads blobs first (each verified to its CID) and then does an atomic, swap-guarded write of the ing.dasl.masl, app.szzt.deployment, and app.szzt.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 @szzt/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