# App platform: repo-hosted static sites (the Serve primitive) Date: 2026-08-06 Goal: serve static sites and apps out of the account's atproto repo — files as blobs, a manifest as a record — on the deployment's own hostnames (apex + one-label subdomains), in a new `@pdsjs/sites` package behind a single hostname-dispatch seam in core. First of five declarative app-platform primitives (Serve, Grant, Query, React, Schedule); this spec designs Serve fully and sketches the rest so nothing in Serve forecloses them. ## 0. Why this shape - **The repo is the deploy artifact.** A site is ordinary repo data: blobs uploaded with `com.atproto.repo.uploadBlob`, one `dev.pdsjs.site.deploy` record written with `putRecord`. Deploys ride the firehose, survive migration, are covered by backup and CAR export, and need zero new write endpoints or auth surface. The server side of Serve is read-only. - **Origin isolation is the security model.** Site content serves only on hostnames that are not the PDS hostname. The PDS origin keeps its invariant: `com.atproto.sync.getBlob` forces `Content-Disposition: attachment` and a sandbox CSP precisely so repo content cannot execute next to the account session cookie and OAuth endpoints. Sites do not weaken that; they move execution to origins that hold no credentials. - **The server side is declarative.** An app is a configuration of fixed primitives, never third-party code executing in the PDS process. That line is what keeps the eventual "install an app" story reviewable. ## 1. Packaging and module boundaries New workspace package `@pdsjs/sites`, workspace-only dependency on `@pdsjs/core` (for `repo` utilities and port typedefs), no external dependencies. ``` packages/sites/src/ index.js re-exports hostname.js hostnameToSite(hostname, siteDomain, apexName) → string|null lexicons.js dev.pdsjs.site.deploy / dev.pdsjs.app.manifest / dev.pdsjs.app.install manifest.js loadSite(): record read → decode → base merge → path map (LRU) serve.js path normalization, lookup order, header assembly, MIME table handler.js createSiteHandler(ctx) → (request, url) => Response|null browser.js createSiteBrowser(ctx) → SiteBrowserPort (account UI listing) deploy.js pure walk/diff/record-build helpers for the CLI cli.js pdsjs-site deploy CLI (the only file importing node:fs) ``` ### Dependency direction `@pdsjs/core` must not depend on `@pdsjs/sites`. Core exposes a hook; sites plugs into it: - The constructor gains `config.siteHandler` and `config.siteBrowser`. - `fetch()` gains one hostname check at its top (§4). - The platform packages construct the handler from ports + env and pass it in. A deployment that does not set `PDS_SITE_DOMAIN` never installs or loads the package, and core's behavior is byte-identical to today. ### Handler convention `createSiteHandler(ctx)` receives ports and config, never the `PersonalDataServer` instance: ```js { actorStorage, blobs, getDid, siteDomain, apexName } ``` Handlers are closures over that ctx — unit-testable without standing up a PDS, and structurally unable to reach PDS internals. This is the same convention space routes established. ## 2. Lexicons Defined as plain JSON in `packages/sites/src/lexicons.js`. Record validation fails open for unresolvable collections, so the schemas gate nothing at write time; they exist for the CLI, tooling, and eventual publication. ### `dev.pdsjs.site.deploy` — one record per site, rkey = site name The rkey is a lowercase DNS label; it binds the site to `.`, and the configured apex name (default `home`) binds to the apex. - `files` (required): array of `{path, blob, contentType?, cacheControl?}`. An array of objects rather than a map because Lexicon has no open-keyed map type — and each entry embeds a real `{$type: "blob", ref, mimeType, size}` value, which is exactly what `findBlobRefs` walks, so every deployed asset is protected from orphan GC by the record that serves it. Files dropped by a redeploy become orphans and the hourly reaper collects them after 24h. - `fallback`: path served with status 200 for any unmatched request path (SPA client-side routing). - `base`: `com.atproto.repo.strongRef` to another `dev.pdsjs.site.deploy` record in the same repo whose files merge beneath this record's (this record wins on conflicts). One hop only; a base may not itself have a base. This is the headroom valve for the 1 MB record write cap: one record holds roughly 5–7k files (~130–180 bytes of DAG-CBOR per entry), a base doubles it. - `createdAt`. `contentType` overrides the stored blob mime at serve time. Upload-time mime sniffing only recognizes image formats, so text types carry whatever Content-Type the deploy client sent; the manifest field is the deliberate signal and the recovery path for blobs uploaded with generic tools. `cacheControl` is a single string, not an open header map — an open map is a header-injection surface, and every other header is either dedicated (`contentType`) or the server's job. ### `dev.pdsjs.app.manifest` — published in the developer's repo `name`, `description`, `icon` (image blob), `site` (strongRef to the `dev.pdsjs.site.deploy` record holding the app's frontend), `scopes` (strings in the host's OAuth scope grammar), `primitives` (enum array: serve/grant/query/react/schedule), `external` (boolean: the app talks to services beyond the user's own PDS — surfaced prominently at install), `createdAt`. ### `dev.pdsjs.app.install` — written to the user's repo at install (rkey tid) `manifest` (strongRef — the CID pin freezes the reviewed version; a changed manifest requires re-consent), `grantedScopes`, `site` (local site name holding this install's copied frontend), `config` (unknown), `createdAt`. ## 3. Serving semantics Per request, in order: 1. **Hostname → site name** (`hostname.js`). Exact `siteDomain` match → `apexName`. `