chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Reference-parity: firehose events, rpc scopes, DPoP nonces, migration endpoints
Date: 2026-07-30
Goal: close the four interop gaps against the reference bluesky-social/atproto
PDS, adapted to pds.js's single-actor, port-based architecture. Reference
behavior is the design authority; where pds.js has no equivalent subsystem
(email, multi-account), the adaptation is documented below.
1. Firehose completeness
1a. #commit gains since and prevData
since= therevof the previous commit (nullon the first commit). Already available increateCommitviagetLatestCommit().prevData= thedata(MST root) CID of the previous commit, read by fetching the previous commit block and decoding it (same DAG-CBOR parsing the CAR loader uses). Omitted on the first commit.- Both are stored in the event payload at commit time so replayed events carry them too. Old stored events without the fields replay as before (fields absent), which is valid per the lexicon (both optional/nullable).
1b. Typed events: #identity, #account, #sync
Stored event payloads are opaque CBOR decoded by formatFirehoseEvent, so a
type discriminator is added inside the payload — no storage schema or port
changes. putEvent's commitCid column gets '' for non-commit events.
Wire shapes (from com.atproto.sync.subscribeRepos lexicon):
#identity:{seq, did, time, handle}#account:{seq, did, time, active, status?}#sync:{seq, did, blocks, rev, time}—blocksis a CAR containing the current commit block; announces non-inductive state (v1.1 semantics).
Emission points (reference order):
/initon a fresh PDS:#identity→#account (active); if a commit already exists (re-init), also#sync./initre-run that changes the handle:#identity.com.atproto.repo.importRepo(new, §4):#syncafter the import commit.activateAccount/deactivateAccount(new, §4):#account.
Each typed event consumes a sequence number from the same per-actor sequence
as commits. The events table becomes the sequence authority (every commit
already writes an event row): ActorStoragePort gains
getLatestSeq(): Promise<number> (max seq in the events table, 0 when empty)
and createCommit switches from prevCommit.seq + 1 to getLatestSeq() + 1.
2. rpc: OAuth scopes
Extend packages/core/src/scope.js:
- Syntax (reference
@atproto/oauth-scopes):rpc:<lxm>?aud=<aud>,rpc:*?aud=<aud>,rpc:<lxm>withaudin query only, plusrpc?lxm=X&lxm=Y&aud=Zrepeatable-param form.audis a DID, adid#serviceIdref, or*. ScopePermissions.allowsRpc(lxm, aud); matching follows the May 2026 reference change: the scope check uses the combineddid#serviceIdform, while outbound service JWTs keep the bare DIDaud(proposal 0014 phase 1). Lxm comparison is case-insensitive (Feb 2026 reference change).- Enforcement: the
app.bsky.*AppView proxy andatproto-proxyheader path require full access ORallowsRpc(lxm,${serviceDid}#${serviceId}).getServiceAuth(§4) enforces the same on itsaud+lxmparams.
3. DPoP nonces
Per RFC 9449 §8 / atproto OAuth spec (AS must supply nonces):
- Nonce = base64url(HMAC-SHA256(jwtSecret, "dpop-nonce:" + floor(now/300s))) truncated to 32 bytes of input material; current and previous 5-minute windows are accepted (no storage).
/oauth/parand/oauth/token: DPoP proofs must carry a validnonceclaim. Missing/stale → HTTP 400{"error":"use_dpop_nonce"}with a freshDPoP-Nonceresponse header. Successful responses also carryDPoP-Nonce.- Server metadata unchanged (
dpop_signing_alg_values_supportedalready advertised). Resource-server nonces stay unimplemented (optional in spec). - The e2e DPoP client helper learns the standard retry-on-
use_dpop_noncedance, which is itself the compliance test.
4. Account migration endpoints
New XRPC routes (all require full-access auth unless noted):
com.atproto.server.getServiceAuth(GET): paramsaud(DID ordid#serviceId),lxm(required here — pds.js always enforces a method),exp(optional, capped at now+1h). Mints an ES256 service JWT via the existingcreateServiceJwt,aud= bare DID. Granular tokens need a matchingrpc:scope.com.atproto.server.checkAccountStatus(GET): returns{activated, validDid, repoCommit, repoRev, repoBlocks, indexedRecords, privateStateValues: 0, expectedBlobs, importedBlobs}. Counts come from the storage ports (listAllRecords,listBlobs, block walk likegetRepo).validDid= stored DID parses and the signing key exists.com.atproto.repo.importRepo(POST, CAR body): loads via the existingloadRepositoryFromCarinto actor storage. The CAR's commit DID must match the authed DID. Afterwards emits a#syncevent. Allowed while deactivated (that's the migration flow).com.atproto.repo.listMissingBlobs(GET): walks all records withfindBlobRefs, diffs againstlistBlobs, returns{blobs: [{cid, recordUri}], cursor}(cursor = last record URI, limit default 500).com.atproto.server.activateAccount/deactivateAccount(POST): flips a newaccountStatusmetadata value (activedefault /deactivated). Deactivated blocks record/blob writes (409AccountDeactivated), is reflected bygetRepoStatus/listRepos/checkAccountStatus, and emits#account.deleteAfteris accepted and ignored (no scheduler).- PLC operations (
com.atproto.identity.*), adapted for a PDS with no email:getRecommendedDidCredentials(GET):{alsoKnownAs: [at://handle], verificationMethods: {atproto: did:key}, rotationKeys: [did:key], services: {atproto_pds: {type: 'AtprotoPersonalDataServer', endpoint: https://hostname}}}. did:key encoding lifted fromscripts/setup.jsintopackages/core/src/plc.js(shared with the script).requestPlcOperationSignature(POST): succeeds as a no-op — there is no email channel; no token is issued and none is required.signPlcOperation(POST): merges the request body over the recommended credentials, setsprevfromGET {plcUrl}/{did}/log/last, signs with the repo signing key, returns{operation}. Thetokenfield is accepted and ignored (documented).submitPlcOperation(POST): POSTs{operation}to{plcUrl}/{did}.- New constructor option
plcUrl(defaulthttps://plc.directory).
Storage port additions
ActorStoragePort gains getAccountStatus(): Promise<string|null> and
setAccountStatus(status: string): Promise<void>, implemented in the
metadata KV table both sqlite and the Cloudflare DO already have; the
readonly routing shim returns 'active'; test mocks updated.
Non-goals
Multi-account, email/2FA, app passwords, admin/moderation endpoints,
getBlocks/listReposByCollection, resource-server DPoP nonces, incremental
(since) CAR diffs in getRepo, and rate limiting stay out of scope.
Testing
TDD throughout. Unit: scope parsing (scope.test.js), nonce
generation/windowing and PLC helpers (new test files), event formatting.
E2E: firehose event assertions over WebSocket (identity/account/sync +
since/prevData on commits), nonce retry flow in the DPoP helper, and a full
migration round-trip (deactivate → export via getRepo → import into the same
PDS → listMissingBlobs → checkAccountStatus → activate) on all three
platforms via the existing suite.