- TypeScript 100%
- Docker <1%
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:
- 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). - Publish its public half as the
#atproto_spaceverification method in the authority's DID document (fordid:plc, via a PLC operation), plus this host's URL as the DID's#atproto_pdsservice (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. - When a credential is requested for a space, the host resolves the authority's DID document, reads the published
#atproto_spacekey, 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,
429past 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_spaceentry, 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, andnotifyWriteneed 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 (
notifySpaceDeletedfan-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.