~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
89 lines · 8.6 KB Raw History

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 test green before commit.
  • Wire shapes from com.atproto.sync.subscribeRepos lexicon (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) and prevData (prev MST root) — verify second commit's since === first commit's rev.
  • Verify RED. Implement: parseCommitBlock in repo.js; in createCommit read prev commit block → store since/prevData in event payload; emit in formatFirehoseEvent. 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 #identity then #account frames 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=*, repeated lxm params, 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 returns use_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, #account frame 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 + #sync frame → 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.js behavior; 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.md implemented-endpoint tables and CHANGELOG.md (0.9.0 entry). Full suite + build + lint green. Commit.