A Space host for truly public data

README.md

public-spaces #

A space host for AT Protocol permissioned data (proposal 0016) that serves public spaces: it issues space credentials to anyone who asks — including unauthenticated requests — and the credentials are long-lived and freely reusable.

Tracks the spaces alpha wire format (atproto PR #5187 / publishing branch #5423, @atproto/space@alpha, proposal text as of 2026-08-19): DPoP-bound credentials, service-identifier notify registrations, unregisterNotify.

In proposal-0016 terms, every space on this host is policy: public / appAccess: #open: any user, any app. The delegation-token exchange still works (spec-conformant clients send one), but it is never required, because there is nothing to protect: the credential is free.

What is required is the DPoP proof. Since the alpha, a space credential is not a bearer token: it is bound at issuance to a key the application holds (cnf.jkt), and every request that presents it also carries a proof signed by that key (Authorization: DPoP <credential> + DPoP: <proof>). That binding protects the repo hosts — a host handed a credential to serve one repo can't replay it against the others — so there is no public-host exemption for it, and alpha PDSes reject unbound credentials anyway.

The host has no identity of its own and stores no DID→key bindings. It answers for space authority DIDs — typically did:plcs managed by their owners — whose DID documents point at this service; a key from the host's open key pool signs for a DID exactly when that DID's document publishes it. Members write records into their own PDSes (the repo hosts); this service only fills the space-host role: minting credentials, tracking the writer set, and routing write notifications.

What it implements #

Endpoint Notes
POST /keys Add a signing key to the host's anonymous key pool (supply a hex private key, or let the host generate one). Returns the public half and the #atproto_space / #atproto_space_host entries to publish. Open, idempotent, rate-limited — see below.
GET /authority?did=... Whether the host can currently sign for a DID: the document's published key, and whether the pool holds it.
POST /xrpc/com.atproto.space.getSpaceCredential Mints a space credential for any requester that presents a DPoP proof (RFC 9449: typ: dpop+jwt, ES256, htm/htu/jti/iat, no ath). Delegation token optional (parsed for attribution when present). Credentials are ES256K JWTs (typ: atproto-space-credential+jwt, kid: #atproto_space, cnf.jkt = the proof key's thumbprint), long-lived (default 30 days), multi-use. Errors: MissingDpopProof, BadDpopProof, NoSigningKey, SpaceNotFound.
GET /xrpc/com.atproto.space.listRepos The writer set (sync boundary) with each repo's last-reported rev and commit hash, cursor-paginated.
POST /xrpc/com.atproto.space.registerNotify Subscribe a service identifier (did:web:syncer.example.com#atproto_space_syncer; a bare DID means its #atproto_pds) to write notifications for a space. The endpoint is resolved from the DID document (ServiceNotResolvable otherwise); re-registering extends the expiry.
POST /xrpc/com.atproto.space.unregisterNotify Withdraw a registration. Idempotent.
POST /xrpc/com.atproto.space.notifyWrite Receives write notifications from repo hosts (service-auth verified; rev must be a TID, hash required), advances the writer set, and forwards to registered syncers with service auth addressed to each registered identifier, best-effort.

Spaces are auto-created on first reference by default: any well-formed at://<authorityDid>/space/<type>/<skey> space ref works immediately. There is no management surface — no simplespace methods (a PDS requirement in the proposal, not a space-host one; com.atproto.space.getSpace moved there in the alpha) and no admin API; with every space public and auto-created there is nothing to administer. Set AUTO_CREATE_SPACES=false to serve only spaces already present in the database.

The vendored alpha lexicons live in lexicons/ for reference (copied from the permissioned-data-alpha branch).

Run it #

npm install
npm run build
VERIFY_AUTHORITY=false npm start   # dev mode: skip DID-document checks

Then, in another terminal:

node examples/demo-client.mjs

which walks the flow: generate a DPoP key → exchange a proof (and nothing else) for a credential → listRepos under the DPoP scheme (dev mode; in production a pooled key published in the authority's DID document does the signing).

Or by hand (the credential request needs a DPoP header, which is easiest to produce with the demo client or @atproto/space@alpha's createDpopProof):

curl -X POST http://localhost:3000/keys \
  -H 'content-type: application/json' -d '{}'

curl http://localhost:3000/authority?did=did:plc:yourauthority

curl -X POST http://localhost:3000/xrpc/com.atproto.space.getSpaceCredential \
  -H 'content-type: application/json' \
  -H "DPoP: $PROOF" \
  -d '{"space": "at://did:plc:yourauthority/space/com.example.demo/lobby"}'

Configuration (environment) #

Variable Default Description
PORT 3000 Listen port.
PUBLIC_URL unset This host's canonical public URL. Its origin is what DPoP proofs' htu is checked against — set it whenever the host is behind a proxy (otherwise X-Forwarded-Proto/Host are used) — it fills in the service endpoints in POST /keys instructions, and requests arriving under any other hostname are 308-redirected to it.
DATA_DIR ./data Holds the sqlite database (spaces, writer sets, registrations, and per-authority signing keys).
CREDENTIAL_TTL_SECONDS 2592000 (30 days) Credential lifetime. Long-lived by design.
REGISTRATION_TTL_SECONDS 86400 (24h) notifyWrite registration lifetime; syncers re-register to renew.
AUTO_CREATE_SPACES true Mint credentials for (and lazily create) any space under a registered authority.
REQUIRE_CREDENTIALS false Require a valid DPoP-bound space credential on listRepos / registerNotify / unregisterNotify. Off by default — the host is public either way; on keeps clients spec-shaped.
VERIFY_NOTIFY_AUTH true Verify inter-service auth on notifyWrite (resolves the writer's DID to its signing key; requires network access).
VERIFY_AUTHORITY true Sign only with keys the authority's DID document publishes (see "The key pool"). Disable for local development.
KEY_TTL_SECONDS 7776000 (90 days) Sliding lifetime of pooled keys; refreshed on every use, so active keys never expire.
KEY_UPLOADS_PER_HOUR 10 Rate limit for POST /keys, per IP.

The key pool: no DID→key binding #

The host stores no binding between DIDs and keys. It keeps an anonymous pool of signing keys, and the DID document is the only thing that connects an authority to a key:

  1. Add a private key to the pool with POST /keys ({"signingKey": "<hex>"}, or an empty body to have the host generate one — that key never leaves the host).
  2. Publish its public half as the #atproto_space verification method in the authority's DID document (for did:plc, via a PLC operation), plus this host's URL as the DID's #atproto_pds service (and #atproto_space_host, which the proposal text names — but the alpha PDS and the Bulletin reference app both locate a space host through #atproto_pds, so that is the entry that matters today). In practice this means the authority is a dedicated DID, not a user's account DID.
  3. When a credential is requested for a space, the host resolves the authority's DID document, reads the published #atproto_space key, and signs only if the pool holds the matching private key — no match, no signing (NoSigningKey).

Consequences of this shape:

  • Uploads can be open (rate-limited per IP, 429 past the limit). An unreferenced key is inert: it can only ever sign for a DID whose owner chooses to publish it, and only the owner can update the DID document.
  • Rotation has no ceremony and no catch-22. A DID document carries a single #atproto_space entry, so with a stored binding, rotation would deadlock (the host holds the old key while the doc names the new one). With the pool, add the new key and flip the document — in either order; the host follows whatever the document says (published-key lookups are cached ~5 minutes when found, ~30 seconds when not, so a fresh PLC operation is picked up quickly).
  • Keys expire when unused (sliding TTL, default 90 days, refreshed on every use), so abandoned uploads collect no risk. Re-uploading a key refreshes it.
  • Everything except signing operates on the DID alone. listRepos, registerNotify, unregisterNotify, and notifyWrite need no key material; forwarded notifications are signed when the pool can sign for the authority and sent unsigned otherwise.

Repo hosts (members' PDSes) verify credentials against the document's published key, offline — which is exactly the check this host applies to itself before signing.

Set VERIFY_AUTHORITY=false to skip document resolution entirely (local development with made-up DIDs; the host signs with ephemeral per-DID keys).

Development #

npm test          # vitest: credential, space-uri, and end-to-end server suites
npm run typecheck
npm run build     # tsdown -> dist/

Caveats / departures from the proposal #

  • Freely issued credentials are the point of this host, not an oversight. Anything written into a space here is world-readable; the access "perimeter" is a formality that keeps clients and repo hosts spec-conformant.
  • Client attestations are accepted but not verified against the client's JWKS (matching the current state of the reference implementation).
  • Space deletion (notifySpaceDeleted fan-out) is not implemented; with no management surface there is currently no way to delete a space.
  • The proposal is a draft: "details, terminology, and behaviors are all likely to change," and this implementation tracks the lexicons vendored in lexicons/.

Allocating an authority DID #

examples/allocate-authority.mjs creates a dedicated did:plc for a space authority: it generates a rotation key and a space signing key (saved to data/authority-keys.json, gitignored — back it up; the rotation key is the only thing that can change the DID later), adds the signing key to the host's pool, and builds a genesis operation publishing it as #atproto_space and #atproto (so forwarded notifications' service auth verifies), with #atproto_pds and #atproto_space_host pointing at the host. Dry-run by default; --commit publishes to plc.directory and waits for GET /authority to report canSign: true.

Space browser (/browse) #

A proof-of-concept syncer built into the host. Paste a URI and the host fetches it the way any application would — credential from the space host with a DPoP proof and no delegation token, then reads under the DPoP scheme. What you get depends on the URI's depth:

URI Shows Via
at://<authority>/space/<type>/<skey> the writer set (did, rev, hash) listRepos on the space host
…/<authorDid> that writer's records listRecords on the author's repo host (#atproto_pds)
…/<authorDid>/<collection> records in one collection listRecords?collection=
…/<authorDid>/<collection>/<rkey> one record getRecord

Results link to each other (writer → records → record; breadcrumbs back up), and pages follow cursor. The space host is found from the authority's DID document (#atproto_space_host, else #atproto_pds). It succeeds exactly when the space host issues credentials to unauthenticated requests; against any other host it stops at the credential step and says so, with each step's outcome shown. The same flow is available as JSON at GET /browse/api?uri=<at-uri>[&cursor=&limit=] ({ error: { step, message, upstream } } on failure). Credentials are cached per space until shortly before they expire, one DPoP key per credential.

Deploy (fly.io) #

fly.toml and Dockerfile are included. The sqlite database lives on a volume mounted at /data.

fly apps create public-spaces
fly volumes create data --region ewr --size 1
fly deploy
node examples/demo-client.mjs https://public.chrispardy.dev did:plc:yourauthority

PUBLIC_URL must be the origin clients actually reach, or every DPoP proof will fail its htu check. To serve from your own domain: fly certs add spaces.example.com, add the DNS records it prints (a CNAME to public-spaces.fly.dev for a subdomain, or A/AAAA to the app's IPs for an apex), set PUBLIC_URL = "https://spaces.example.com" in fly.toml, and fly deploy. The .fly.dev name then redirects to the canonical one. Do this before publishing the authority DID document, which hard-codes the URL.