chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
PDS Migration Completion Plan
For Claude: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Complete migration of src/pds.js to hexagonal architecture in packages/, achieving feature parity for both Cloudflare and Node adapters with full E2E test coverage.
Current State: Phase 1-7 of the original plan implemented. Core packages exist but packages/core/pds.js only has ~13 routes. The original src/pds.js has ~40+ routes including OAuth, MST, proxying, and scope enforcement.
Phase 1: Extend StoragePort Interface
Task 1.1: Add OAuth storage methods to ports.js
Files:
- Modify:
packages/core/ports.js
Step 1: Add OAuth request storage typedef
Add to the StoragePort typedef:
/**
* @property {(requestUri: string) => Promise<Object|null>} getOAuthRequest
* @property {(requestUri: string, data: Object, expiresAt: number) => Promise<void>} putOAuthRequest
* @property {(requestUri: string) => Promise<void>} deleteOAuthRequest
*/
Step 2: Add OAuth token storage typedef
/**
* @property {(tokenId: string) => Promise<Object|null>} getOAuthToken
* @property {(tokenId: string, data: Object) => Promise<void>} putOAuthToken
* @property {(tokenId: string) => Promise<void>} deleteOAuthToken
* @property {(did: string) => Promise<Array>} listOAuthTokensByDid
*/
Step 3: Add DPoP jti tracking typedef
/**
* @property {(jti: string, expiresAt: number) => Promise<boolean>} checkAndStoreDpopJti - Returns true if fresh (not seen before)
* @property {() => Promise<void>} cleanupExpiredDpopJtis
*/
Step 4: Add preferences storage typedef
/**
* @property {() => Promise<Array>} getPreferences
* @property {(preferences: Array) => Promise<void>} setPreferences
*/
Task 1.2: Implement OAuth storage in D1 adapter
Files:
- Modify:
packages/storage-sqlite/index.js
Step 1: Add OAuth tables to schema (will be created in Cloudflare adapter)
The D1 adapter assumes tables exist. Add these methods to createD1Storage:
async getOAuthRequest(requestUri) {
const row = sql.exec('SELECT data, expires_at FROM oauth_requests WHERE request_uri = ?', requestUri).one()
if (!row) return null
if (row.expires_at < Date.now()) {
sql.exec('DELETE FROM oauth_requests WHERE request_uri = ?', requestUri)
return null
}
return JSON.parse(row.data)
},
async putOAuthRequest(requestUri, data, expiresAt) {
sql.exec(
'INSERT OR REPLACE INTO oauth_requests (request_uri, data, expires_at) VALUES (?, ?, ?)',
requestUri, JSON.stringify(data), expiresAt
)
},
async deleteOAuthRequest(requestUri) {
sql.exec('DELETE FROM oauth_requests WHERE request_uri = ?', requestUri)
},
async getOAuthToken(tokenId) {
const row = sql.exec('SELECT data FROM oauth_tokens WHERE token_id = ?', tokenId).one()
return row ? JSON.parse(row.data) : null
},
async putOAuthToken(tokenId, data) {
sql.exec(
'INSERT OR REPLACE INTO oauth_tokens (token_id, did, data) VALUES (?, ?, ?)',
tokenId, data.did, JSON.stringify(data)
)
},
async deleteOAuthToken(tokenId) {
sql.exec('DELETE FROM oauth_tokens WHERE token_id = ?', tokenId)
},
async listOAuthTokensByDid(did) {
const rows = sql.exec('SELECT data FROM oauth_tokens WHERE did = ?', did).toArray()
return rows.map(r => JSON.parse(r.data))
},
async checkAndStoreDpopJti(jti, expiresAt) {
const existing = sql.exec('SELECT 1 FROM dpop_jtis WHERE jti = ?', jti).one()
if (existing) return false
sql.exec('INSERT INTO dpop_jtis (jti, expires_at) VALUES (?, ?)', jti, expiresAt)
return true
},
async cleanupExpiredDpopJtis() {
sql.exec('DELETE FROM dpop_jtis WHERE expires_at < ?', Date.now())
},
async getPreferences() {
const row = sql.exec("SELECT value FROM metadata WHERE key = 'preferences'").one()
return row ? JSON.parse(row.value) : []
},
async setPreferences(preferences) {
sql.exec("INSERT OR REPLACE INTO metadata (key, value) VALUES ('preferences', ?)", JSON.stringify(preferences))
},
Step 2: Add same methods to better-sqlite3 adapter
Add prepared statements and implement the same methods for createBetterSqliteStorage. Include table creation in the schema initialization:
db.exec(`
-- existing tables...
CREATE TABLE IF NOT EXISTS oauth_requests (request_uri TEXT PRIMARY KEY, data TEXT, expires_at INTEGER);
CREATE TABLE IF NOT EXISTS oauth_tokens (token_id TEXT PRIMARY KEY, did TEXT, data TEXT);
CREATE TABLE IF NOT EXISTS dpop_jtis (jti TEXT PRIMARY KEY, expires_at INTEGER);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_did ON oauth_tokens(did);
CREATE INDEX IF NOT EXISTS idx_dpop_jtis_expires ON dpop_jtis(expires_at);
`)
Step 3: Run tests
Run: npm test
Expected: Existing tests pass
Step 4: Commit
git add packages/core/ports.js packages/storage-sqlite/index.js
git commit -m "feat: add OAuth and DPoP storage methods to StoragePort"
Phase 2: Extract MST Module
Task 2.1: Create MST implementation
Files:
- Create:
packages/core/mst.js
Step 1: Extract MST functions from src/pds.js
Copy these functions to packages/core/mst.js:
getKeyDepth(already in repo.js, import it)- MST node structure and constants
buildMstNode/insertIntoMst/deleteFromMstserializeMstNode/deserializeMstNodegetMstEntries- walk tree and yield all entriesgetMstProof- get inclusion proof for a key
// packages/core/mst.js
import { cborEncode, cborDecode, createCid, cidToString, getKeyDepth } from './repo.js'
/**
* MST Node structure
* @typedef {Object} MstNode
* @property {Array<{key: string, cid: string, left: string|null}>} entries
* @property {string|null} right - Rightmost subtree CID
*/
/**
* Build MST from sorted records
* @param {Array<{key: string, cid: string}>} records - Sorted by key
* @param {Function} putBlock - Storage function to persist blocks
* @returns {Promise<string>} Root CID
*/
export async function buildMst(records, putBlock) {
// Implementation from src/pds.js
}
/**
* Walk MST and yield all entries
* @param {string} rootCid
* @param {Function} getBlock
* @yields {{key: string, cid: string}}
*/
export async function* walkMst(rootCid, getBlock) {
// Implementation from src/pds.js
}
/**
* Get all blocks needed for a record proof
* @param {string} rootCid
* @param {string} collection
* @param {string} rkey
* @param {Function} getBlock
* @returns {Promise<Array<{cid: string, data: Uint8Array}>>}
*/
export async function getMstProof(rootCid, collection, rkey, getBlock) {
// Implementation from src/pds.js
}
Step 2: Update packages/core/index.js
export * from './mst.js'
Step 3: Commit
git add packages/core/mst.js packages/core/index.js
git commit -m "feat: extract MST implementation to @pdsjs/core"
Phase 3: Extract Scope Enforcement
Task 3.1: Create scope module
Files:
- Create:
packages/core/scope.js - Modify:
packages/core/auth.js(remove duplicates)
Step 1: Create scope.js
Extract from src/pds.js:
parseRepoScopeparseBlobScopematchesMimeparseScopesForDisplayScopePermissionsclassScopeMissingErrorclass
// packages/core/scope.js
/**
* Parse repo scope string
* @param {string} scope - e.g., "repo:app.bsky.feed.post?action=create"
* @returns {{collection: string, actions: string[]}|null}
*/
export function parseRepoScope(scope) {
// Implementation from src/pds.js
}
/**
* Parse blob scope string
* @param {string} scope - e.g., "blob:image/*"
* @returns {{accept: string[]}|null}
*/
export function parseBlobScope(scope) {
// Implementation from src/pds.js
}
/**
* Check if MIME type matches pattern
*/
export function matchesMime(pattern, mime) {
// Implementation from src/pds.js
}
/**
* Scope permission checker
*/
export class ScopePermissions {
// Implementation from src/pds.js
}
export class ScopeMissingError extends Error {
constructor(message) {
super(message)
this.name = 'ScopeMissingError'
}
}
Step 2: Update index.js
export * from './scope.js'
Step 3: Commit
git add packages/core/scope.js packages/core/index.js
git commit -m "feat: extract scope enforcement to @pdsjs/core"
Phase 4: Create OAuth Module
Task 4.1: Create OAuth handlers
Files:
- Create:
packages/core/oauth.js
Step 1: Create oauth.js with DPoP verification
// packages/core/oauth.js
import { base64UrlDecode, base64UrlEncode, computeJwkThumbprint } from './crypto.js'
/**
* Verify DPoP proof JWT
* @param {string} proof - DPoP header value
* @param {string} method - HTTP method
* @param {string} url - Request URL
* @param {string} [accessToken] - For ath claim verification
* @returns {Promise<{jwk: Object, jti: string, iat: number}>}
*/
export async function verifyDpopProof(proof, method, url, accessToken = null) {
// Implementation from src/pds.js
}
/**
* Create DPoP-bound access token
*/
export async function createDpopAccessToken(did, jkt, scope, secret) {
// Implementation from src/pds.js
}
/**
* Verify DPoP-bound access token
*/
export async function verifyDpopAccessToken(token, secret) {
// Implementation from src/pds.js
}
Step 2: Add consent page renderer
/**
* Render OAuth consent page HTML
* @param {Object} params
* @returns {string} HTML
*/
export function renderConsentPage({
clientId,
clientName,
redirectUri,
scope,
requestUri,
loginHint,
issuer,
}) {
// Implementation from src/pds.js - the large HTML template
}
Step 3: Add OAuth metadata helpers
/**
* Generate OAuth Authorization Server metadata
*/
export function getAuthServerMetadata(issuer) {
return {
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
// ... rest from src/pds.js
}
}
/**
* Generate OAuth Protected Resource metadata
*/
export function getProtectedResourceMetadata(issuer) {
return {
resource: issuer,
authorization_servers: [issuer],
// ... rest from src/pds.js
}
}
Step 4: Commit
git add packages/core/oauth.js
git commit -m "feat: create OAuth module with DPoP and consent page"
Phase 5: Complete PDS Handlers
Task 5.1: Add missing repo handlers
Files:
- Modify:
packages/core/pds.js
Step 1: Add putRecord handler
async handlePutRecord(request, _url, auth) {
// Implementation from src/pds.js
// Uses this.storage.putRecord, this.storage.putBlock, etc.
}
Step 2: Add applyWrites handler
async handleApplyWrites(request, _url, auth) {
// Implementation from src/pds.js
// Handles create, update, delete operations in batch
}
Step 3: Add routes to route table
'/xrpc/com.atproto.repo.putRecord': {
method: 'POST',
auth: 'required',
handler: PersonalDataServer.prototype.handlePutRecord,
},
'/xrpc/com.atproto.repo.applyWrites': {
method: 'POST',
auth: 'required',
handler: PersonalDataServer.prototype.handleApplyWrites,
},
Step 4: Commit
git add packages/core/pds.js
git commit -m "feat: add putRecord and applyWrites handlers"
Task 5.2: Add missing sync handlers
Files:
- Modify:
packages/core/pds.js
Step 1: Add getRepo handler (returns full CAR file)
async handleGetRepo() {
// Build CAR from all blocks using MST
}
Step 2: Add getRepoStatus handler
async handleGetRepoStatus() {
const did = await this.getDid()
const commit = await this.storage.getLatestCommit()
return Response.json({
did,
active: true,
rev: commit?.rev,
})
}
Step 3: Add sync getRecord handler (returns CAR with proof)
async handleSyncGetRecord(url) {
// Uses getMstProof to build minimal CAR
}
Step 4: Add subscribeRepos handler (WebSocket/SSE)
async handleSubscribeRepos(request, url) {
// Implementation from src/pds.js
// Returns streaming response with repo events
}
Step 5: Add listRepos handler
async handleListRepos() {
const did = await this.getDid()
const commit = await this.storage.getLatestCommit()
return Response.json({
repos: did ? [{ did, head: commit?.cid, rev: commit?.rev, active: true }] : []
})
}
Step 6: Add routes and commit
git add packages/core/pds.js
git commit -m "feat: add sync handlers (getRepo, getRepoStatus, getRecord, subscribeRepos, listRepos)"
Task 5.3: Add identity and preferences handlers
Files:
- Modify:
packages/core/pds.js
Step 1: Add resolveHandle handler
async handleResolveHandle(url) {
const handle = url.searchParams.get('handle')
const storedHandle = await this.storage.getHandle()
if (handle === storedHandle) {
const did = await this.getDid()
return Response.json({ did })
}
return Response.json(
{ error: 'InvalidRequest', message: 'Handle not found' },
{ status: 400 }
)
}
Step 2: Add preferences handlers
async handleGetPreferences(_request, _url, auth) {
const preferences = await this.storage.getPreferences()
return Response.json({ preferences })
}
async handlePutPreferences(request, _url, auth) {
const { preferences } = await request.json()
await this.storage.setPreferences(preferences)
return new Response(null, { status: 200 })
}
Step 3: Add routes and commit
git add packages/core/pds.js
git commit -m "feat: add resolveHandle and preferences handlers"
Task 5.4: Add OAuth handlers
Files:
- Modify:
packages/core/pds.js
Step 1: Import OAuth helpers
import {
verifyDpopProof,
createDpopAccessToken,
verifyDpopAccessToken,
renderConsentPage,
getAuthServerMetadata,
getProtectedResourceMetadata,
} from './oauth.js'
import { ScopePermissions, ScopeMissingError } from './scope.js'
Step 2: Add OAuth metadata handlers
handleOAuthAuthServerMetadata(url) {
const issuer = `${url.protocol}//${url.host}`
return Response.json(getAuthServerMetadata(issuer))
}
handleOAuthProtectedResource(url) {
const issuer = `${url.protocol}//${url.host}`
return Response.json(getProtectedResourceMetadata(issuer))
}
async handleOAuthJwks() {
// Return public key JWK
}
Step 3: Add PAR handler
async handleOAuthPar(request, url) {
// Verify DPoP, validate client, store request, return request_uri
}
Step 4: Add authorize handler (GET shows consent, POST processes)
async handleOAuthAuthorize(request, url) {
if (request.method === 'GET') {
// Render consent page
} else {
// Process consent, issue code, redirect
}
}
Step 5: Add token handler
async handleOAuthToken(request, url) {
// Handle authorization_code and refresh_token grants
}
Step 6: Add revoke handler
async handleOAuthRevoke(request) {
// Revoke token
}
Step 7: Add all OAuth routes
'/.well-known/oauth-authorization-server': { handler: ... },
'/.well-known/oauth-protected-resource': { handler: ... },
'/oauth/jwks': { handler: ... },
'/oauth/par': { method: 'POST', handler: ... },
'/oauth/authorize': { handler: ... },
'/oauth/token': { method: 'POST', handler: ... },
'/oauth/revoke': { method: 'POST', handler: ... },
Step 8: Update authenticate method for DPoP
async authenticate(request) {
const authHeader = request.headers.get('authorization')
if (!authHeader) return null
// Handle both Bearer and DPoP tokens
const dpopMatch = authHeader.match(/^DPoP\s+(.+)$/i)
if (dpopMatch) {
// Verify DPoP proof and token
}
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i)
if (bearerMatch) {
// Existing Bearer token logic
}
return null
}
Step 9: Add scope enforcement to write handlers
Update handleCreateRecord, handlePutRecord, handleDeleteRecord, handleUploadBlob to check scopes:
async handleCreateRecord(request, _url, auth) {
// ... existing code ...
// Check scope if OAuth token
if (auth.scope) {
const perms = new ScopePermissions(auth.scope)
if (!perms.allowsRepo(collection, 'create')) {
return Response.json(
{ error: 'Forbidden', message: 'Missing required scope for create on ' + collection },
{ status: 403 }
)
}
}
// ... rest of handler ...
}
Step 10: Commit
git add packages/core/pds.js
git commit -m "feat: add OAuth handlers with DPoP and scope enforcement"
Task 5.5: Add AppView proxying
Files:
- Modify:
packages/core/pds.js
Step 1: Add proxy helper
async proxyToAppView(request, url) {
const proxyUrl = new URL(url.pathname + url.search, this.appviewUrl)
// Create service JWT for auth
const serviceJwt = await createServiceJwt(
await this.getDid(),
this.appviewDid,
await this.getSigningKey()
)
const headers = new Headers(request.headers)
headers.set('Authorization', `Bearer ${serviceJwt}`)
return fetch(proxyUrl.toString(), {
method: request.method,
headers,
body: request.body,
})
}
Step 2: Add atproto-proxy header handling in fetch()
async fetch(request) {
const url = new URL(request.url)
// Check for proxy header
const proxyHeader = request.headers.get('atproto-proxy')
if (proxyHeader) {
return this.handleProxyRequest(request, url, proxyHeader)
}
// ... existing routing logic ...
}
async handleProxyRequest(request, url, proxyHeader) {
// Parse "did:web:api.bsky.app#bsky_appview" format
const hashIdx = proxyHeader.indexOf('#')
if (hashIdx === -1 || hashIdx === 0 || hashIdx === proxyHeader.length - 1) {
return Response.json(
{ error: 'InvalidRequest', message: 'Malformed atproto-proxy header' },
{ status: 400 }
)
}
const did = proxyHeader.slice(0, hashIdx)
const serviceId = proxyHeader.slice(hashIdx + 1)
// Only support known AppView for now
if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
return this.proxyToAppView(request, url)
}
return Response.json(
{ error: 'InvalidRequest', message: 'Unknown proxy service' },
{ status: 400 }
)
}
Step 3: Commit
git add packages/core/pds.js
git commit -m "feat: add AppView proxying with atproto-proxy header"
Task 5.6: Add init and well-known endpoints
Files:
- Modify:
packages/core/pds.js
Step 1: Add /init handler
async handleInit(request) {
const { did, privateKey, handle } = await request.json()
// Check if already initialized
const existingDid = await this.storage.getDid()
if (existingDid) {
return Response.json(
{ error: 'AlreadyInitialized', message: 'PDS already initialized' },
{ status: 400 }
)
}
// Store identity
await this.storage.setDid(did)
if (privateKey) {
const keyBytes = hexToBytes(privateKey)
await this.storage.setPrivateKey(keyBytes)
}
if (handle) {
await this.storage.setHandle(handle)
}
// Clear cache
this._did = null
this._signingKey = null
return Response.json({ success: true })
}
Step 2: Add /.well-known/atproto-did handler
async handleAtprotoDid() {
const did = await this.getDid()
if (!did) {
return new Response('Not Found', { status: 404 })
}
return new Response(did, {
headers: { 'Content-Type': 'text/plain' }
})
}
Step 3: Add root handler
handleRoot() {
return new Response(`ATProto PDS v${VERSION}`, {
headers: { 'Content-Type': 'text/plain' }
})
}
Step 4: Add routes
'/': { handler: PersonalDataServer.prototype.handleRoot },
'/init': { method: 'POST', handler: PersonalDataServer.prototype.handleInit },
'/.well-known/atproto-did': { handler: PersonalDataServer.prototype.handleAtprotoDid },
Step 5: Commit
git add packages/core/pds.js
git commit -m "feat: add init, root, and well-known endpoints"
Phase 6: Update Cloudflare Adapter
Task 6.1: Add OAuth tables to schema
Files:
- Modify:
packages/cloudflare/index.js
Step 1: Update initSchema
async initSchema() {
this.state.storage.sql.exec(`
-- Existing tables
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value BLOB);
CREATE TABLE IF NOT EXISTS blocks (cid TEXT PRIMARY KEY, data BLOB);
CREATE TABLE IF NOT EXISTS records (uri TEXT PRIMARY KEY, cid TEXT, collection TEXT, rkey TEXT, value BLOB);
CREATE TABLE IF NOT EXISTS commits (seq INTEGER PRIMARY KEY, cid TEXT, rev TEXT, prev TEXT);
CREATE TABLE IF NOT EXISTS seq_events (seq INTEGER PRIMARY KEY, did TEXT, commit_cid TEXT, evt BLOB);
CREATE TABLE IF NOT EXISTS blobs (cid TEXT PRIMARY KEY, mime_type TEXT, size INTEGER, created_at INTEGER);
CREATE TABLE IF NOT EXISTS record_blobs (blob_cid TEXT, record_uri TEXT, PRIMARY KEY (blob_cid, record_uri));
-- OAuth tables
CREATE TABLE IF NOT EXISTS oauth_requests (request_uri TEXT PRIMARY KEY, data TEXT, expires_at INTEGER);
CREATE TABLE IF NOT EXISTS oauth_tokens (token_id TEXT PRIMARY KEY, did TEXT, data TEXT);
CREATE TABLE IF NOT EXISTS dpop_jtis (jti TEXT PRIMARY KEY, expires_at INTEGER);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_did ON oauth_tokens(did);
CREATE INDEX IF NOT EXISTS idx_dpop_jtis_expires ON dpop_jtis(expires_at);
`)
}
Step 2: Commit
git add packages/cloudflare/index.js
git commit -m "feat: add OAuth tables to Cloudflare adapter schema"
Phase 7: Create Node E2E Tests
Task 7.1: Create e2e-node.test.js
Files:
- Create:
test/e2e-node.test.js
Step 1: Copy e2e.test.js as starting point
Step 2: Replace wrangler spawn with @pdsjs/node
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from '@pdsjs/node'
const PORT = 8788
const BASE = `http://localhost:${PORT}`
let server
let tempDir
beforeAll(async () => {
// Create temp directory
tempDir = await mkdtemp(join(tmpdir(), 'pds-test-'))
// Start server
server = await createServer({
dbPath: join(tempDir, 'pds.db'),
blobsDir: join(tempDir, 'blobs'),
jwtSecret: 'test-secret',
port: PORT,
password: PASSWORD,
})
await server.listen()
// Initialize PDS
const privKey = randomBytes(32).toString('hex')
await fetch(`${BASE}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
did: DID,
privateKey: privKey,
handle: 'test.local',
}),
})
})
afterAll(async () => {
await server?.close()
await rm(tempDir, { recursive: true, force: true })
})
Step 3: Keep all test cases identical
The test cases themselves should be exactly the same as e2e.test.js.
Step 4: Commit
git add test/e2e-node.test.js
git commit -m "test: create Node E2E tests"
Task 7.2: Add test scripts to package.json
Files:
- Modify:
package.json
Step 1: Add scripts
{
"scripts": {
"test:e2e": "vitest run test/e2e.test.js",
"test:e2e:node": "vitest run test/e2e-node.test.js",
"test:e2e:all": "vitest run test/e2e.test.js test/e2e-node.test.js"
}
}
Step 2: Commit
git add package.json
git commit -m "chore: add Node E2E test scripts"
Phase 8: Cleanup and Verification
Task 8.1: Run all tests
Step 1: Run unit tests
npm test
Step 2: Run Cloudflare E2E tests
npm run test:e2e
Step 3: Run Node E2E tests
npm run test:e2e:node
Step 4: Verify all tests pass
Both E2E test suites should pass with identical behavior.
Task 8.2: Update src/pds.js to use core
Files:
- Modify:
src/pds.js
Option A: Thin wrapper - If Cloudflare-specific logic remains:
- Import PersonalDataServer from @pdsjs/core
- Re-export with any Cloudflare-specific overrides
Option B: Delete - If no Cloudflare-specific logic:
- Update wrangler.toml to point to packages/cloudflare/index.js
- Delete src/pds.js
Step 1: Evaluate which option applies
Check if src/pds.js has any logic not migrated to core.
Step 2: Implement chosen option and commit
git add -A
git commit -m "refactor: complete PDS migration to hexagonal architecture"
Summary
| Phase | Tasks | Estimated Lines |
|---|---|---|
| 1. StoragePort | 2 tasks | ~200 lines |
| 2. MST | 1 task | ~300 lines |
| 3. Scope | 1 task | ~150 lines |
| 4. OAuth | 1 task | ~800 lines |
| 5. PDS Handlers | 6 tasks | ~1200 lines |
| 6. Cloudflare | 1 task | ~20 lines |
| 7. Node E2E | 2 tasks | ~1700 lines |
| 8. Cleanup | 2 tasks | varies |
Success criteria:
- All Cloudflare E2E tests pass
- All Node E2E tests pass with identical behavior
src/pds.jsdeleted or reduced to thin wrapper