chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Reference Parity Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement firehose completeness (#identity/#account/#sync, since/prevData), rpc: OAuth scopes, DPoP nonces, and account-migration endpoints per docs/superpowers/specs/2026-07-30-reference-parity-design.md.
Architecture: All endpoint logic lands in packages/core/src/pds.js (single route table); scope logic in scope.js; DPoP/nonce in oauth.js; PLC helpers in a new plc.js shared with scripts/setup.js. Storage ports gain getLatestSeq and get/setAccountStatus, implemented in sqlite + Cloudflare DO metadata/seq_events tables, the readonly shim, and test mocks.
Tech Stack: plain ESM JS + JSDoc types, vitest, TDD per task, one commit per task.
Global Constraints
- Reference behavior (
~/code/atproto) is authoritative; adaptations documented in the spec. - Every task: failing test first, minimal implementation, full
pnpm testgreen before commit. - Wire shapes from
com.atproto.sync.subscribeReposlexicon (see spec §1b). - Lxm/NSID comparisons case-insensitive.
Task 1: #commit since + prevData
Files: Modify packages/core/src/pds.js (createCommit, formatFirehoseEvent); Test test/e2e.test.js (subscribeRepos assertions).
Interfaces: Produces stored event fields since: string|null, prevData: {$link}|undefined; frame fields since, prevData (CID class). Consumes parseCommitBlock(bytes) helper (new, in repo.js, DAG-CBOR decode of a commit block returning {did, rev, data: string|null, prev}), reused by Task 6 (checkAccountStatus) and Task 2 (#sync CAR).
-
e2e test: commit frames carry
since(prev rev) andprevData(prev MST root) — verify second commit'ssince=== first commit'srev. -
Verify RED. Implement:
parseCommitBlockin repo.js; increateCommitread prev commit block → storesince/prevDatain event payload; emit informatFirehoseEvent. Verify GREEN. Commit.
Task 2: typed events + getLatestSeq
Files: Modify packages/core/src/ports.js, packages/storage-sqlite/src/index.js, packages/cloudflare/src/index.js, packages/readonly/src/index.js, packages/core/src/pds.js, test mocks (test/loader.test.js mock unaffected — no events), test/helpers if needed; Test test/e2e.test.js.
Interfaces: Port gains getLatestSeq(): Promise<number> (0 when empty). New core method emitEvent(type, body) — allocates seq, stores CBOR {type, ...body} via putEvent(seq, did, '', encoded), broadcasts frame. formatFirehoseEvent branches on evtData.type ?? 'commit' → t: '#identity'|'#account'|'#sync'|'#commit'. /init emits #identity {did, handle, time} + #account {did, active: true, time} (+ #sync when a commit exists); handle-changing re-init emits #identity.
-
e2e test: fresh init then subscribeRepos from cursor 0 yields
#identitythen#accountframes before first#commit; createCommit seqs continue after them. - Verify RED. Implement port + adapters + emitEvent + init wiring. Verify GREEN. Commit.
Task 3: rpc: scopes
Files: Modify packages/core/src/scope.js, packages/core/src/pds.js (AppView proxy + atproto-proxy path); Test packages/core/test/scope.test.js, test/e2e.test.js (scope-enforcement describe).
Interfaces: parseRpcScope(scope) → {lxms: string[]|'*', aud: string|'*'}|null; ScopePermissions.allowsRpc(lxm, aud) with aud compared in did#serviceId form, lxm case-insensitive, rpc:* and aud=* wildcards. Proxy paths: full access bypasses; granular tokens require allowsRpc(lxm, ${serviceDid}#${serviceId}) → else 403 InvalidScope.
-
Unit tests: parse
rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app%23bsky_appview,rpc:*?aud=*, repeatedlxmparams, case-insensitive match, reject missing aud. - e2e test: granular token without rpc scope → 403 on app.bsky proxy; with matching rpc scope → passes scope gate.
- Verify RED → implement → GREEN → commit.
Task 4: DPoP nonces
Files: Modify packages/core/src/oauth.js (nonce mint/verify helpers), packages/core/src/pds.js (PAR + token handlers), test/helpers/dpop.js (retry on use_dpop_nonce); Test packages/core/test/oauth.test.js, e2e via existing OAuth flow.
Interfaces: mintDpopNonce(secret, now) / isValidDpopNonce(secret, nonce, now) (HMAC-SHA256, 5-min windows, current+previous accepted). PAR/token: proof without valid nonce → 400 {error:'use_dpop_nonce'} + DPoP-Nonce header; success responses also set DPoP-Nonce.
- Unit tests: nonce validates in current and previous window, rejects two windows back / garbage.
-
e2e: existing DPoP flow keeps passing once helper retries on
use_dpop_nonce(asserts the 400-then-retry path implicitly); add explicit test that PAR without nonce returnsuse_dpop_nonce+ header. - Verify RED → implement → GREEN → commit.
Task 5: account status + activate/deactivate
Files: Modify packages/core/src/ports.js, sqlite + DO adapters (metadata key accountStatus), readonly shim (returns 'active'), packages/core/src/pds.js (routes, write-gating, getRepoStatus/listRepos); Test test/e2e.test.js.
Interfaces: Port: getAccountStatus(): Promise<string|null> / setAccountStatus(status). Routes com.atproto.server.activateAccount/deactivateAccount (POST, full access). Deactivated: record/blob writes → 409 {error:'AccountDeactivated'}; getRepoStatus/listRepos report active:false, status:'deactivated'; #account emitted on both transitions.
-
e2e test: deactivate → createRecord 409, getRepoStatus inactive,
#accountframe observed; activate → writes work again. - Verify RED → implement → GREEN → commit.
Task 6: migration endpoints
Files: Modify packages/core/src/pds.js (routes: server.getServiceAuth, server.checkAccountStatus, repo.importRepo, sync.listMissingBlobs); Test test/e2e.test.js.
Interfaces: getServiceAuth GET params aud (DID or did#serviceId), lxm (required), exp (≤ now+3600) → {token} via existing createServiceJwt (bare-DID aud; rpc-scope gate from Task 3). checkAccountStatus → {activated, validDid, repoCommit, repoRev, repoBlocks, indexedRecords, privateStateValues: 0, expectedBlobs, importedBlobs} (block count via the getRepo walk; expectedBlobs = distinct findBlobRefs over all records; importedBlobs = those present in listBlobs). importRepo POST CAR body → loadRepositoryFromCar, DID must match auth, allowed while deactivated, emits #sync. listMissingBlobs → {blobs:[{cid, recordUri}], cursor} limit default 500.
-
e2e migration round-trip test: create records+blob → checkAccountStatus counts match → deactivate → getRepo CAR → importRepo (same PDS) 200 +
#syncframe → listMissingBlobs empty after re-upload → activate. -
getServiceAuth e2e: full-access token mints JWT with
aud/lxm; granular token without rpc scope → 403. - Verify RED → implement → GREEN → commit.
Task 7: PLC identity endpoints
Files: Create packages/core/src/plc.js (lift publicKeyToDidKey, base58btcEncode, op signing from scripts/setup.js; script imports from core); Modify packages/core/src/pds.js (routes: identity.getRecommendedDidCredentials, identity.requestPlcOperationSignature, identity.signPlcOperation, identity.submitPlcOperation, new plcUrl option); Test packages/core/test/plc.test.js, test/e2e.test.js.
Interfaces: publicKeyToDidKey(compressedPubKey) → did:key:z...; buildPlcOperation({prev, rotationKeys, verificationMethods, alsoKnownAs, services}); signPlcOperationObject(op, cryptoKey) → op with sig. Endpoints per spec §4: recommended credentials from signing key + hostname + handle; sign merges body over recommended, prev from {plcUrl}/{did}/log/last; submit POSTs to {plcUrl}/{did}; request-signature is a documented no-op 200.
-
Unit tests: did:key encoding matches a known vector from
scripts/setup.jsbehavior; operation signing produces verifiable sig. -
e2e (docker PLC): getRecommendedDidCredentials shape; signPlcOperation returns signed op with
prev; submitPlcOperation round-trips against local PLC (update handle alsoKnownAs), then restore. - Verify RED → implement → GREEN → commit.
Task 8: docs + changelog
-
Update
docs/endpoint-comparison.mdimplemented-endpoint tables andCHANGELOG.md(0.9.0 entry). Full suite + build + lint green. Commit.