# 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: ```javascript /** * @property {(requestUri: string) => Promise} getOAuthRequest * @property {(requestUri: string, data: Object, expiresAt: number) => Promise} putOAuthRequest * @property {(requestUri: string) => Promise} deleteOAuthRequest */ ``` **Step 2: Add OAuth token storage typedef** ```javascript /** * @property {(tokenId: string) => Promise} getOAuthToken * @property {(tokenId: string, data: Object) => Promise} putOAuthToken * @property {(tokenId: string) => Promise} deleteOAuthToken * @property {(did: string) => Promise} listOAuthTokensByDid */ ``` **Step 3: Add DPoP jti tracking typedef** ```javascript /** * @property {(jti: string, expiresAt: number) => Promise} checkAndStoreDpopJti - Returns true if fresh (not seen before) * @property {() => Promise} cleanupExpiredDpopJtis */ ``` **Step 4: Add preferences storage typedef** ```javascript /** * @property {() => Promise} getPreferences * @property {(preferences: Array) => Promise} 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`: ```javascript 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: ```javascript 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** ```bash 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` / `deleteFromMst` - `serializeMstNode` / `deserializeMstNode` - `getMstEntries` - walk tree and yield all entries - `getMstProof` - get inclusion proof for a key ```javascript // 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} 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>} */ export async function getMstProof(rootCid, collection, rkey, getBlock) { // Implementation from src/pds.js } ``` **Step 2: Update packages/core/index.js** ```javascript export * from './mst.js' ``` **Step 3: Commit** ```bash 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: - `parseRepoScope` - `parseBlobScope` - `matchesMime` - `parseScopesForDisplay` - `ScopePermissions` class - `ScopeMissingError` class ```javascript // 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** ```javascript export * from './scope.js' ``` **Step 3: Commit** ```bash 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** ```javascript // 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** ```javascript /** * 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** ```javascript /** * 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** ```bash 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** ```javascript async handlePutRecord(request, _url, auth) { // Implementation from src/pds.js // Uses this.storage.putRecord, this.storage.putBlock, etc. } ``` **Step 2: Add applyWrites handler** ```javascript async handleApplyWrites(request, _url, auth) { // Implementation from src/pds.js // Handles create, update, delete operations in batch } ``` **Step 3: Add routes to route table** ```javascript '/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** ```bash 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) ```javascript async handleGetRepo() { // Build CAR from all blocks using MST } ``` **Step 2: Add getRepoStatus handler** ```javascript 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) ```javascript async handleSyncGetRecord(url) { // Uses getMstProof to build minimal CAR } ``` **Step 4: Add subscribeRepos handler** (WebSocket/SSE) ```javascript async handleSubscribeRepos(request, url) { // Implementation from src/pds.js // Returns streaming response with repo events } ``` **Step 5: Add listRepos handler** ```javascript 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** ```bash 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** ```javascript 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** ```javascript 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** ```bash 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** ```javascript import { verifyDpopProof, createDpopAccessToken, verifyDpopAccessToken, renderConsentPage, getAuthServerMetadata, getProtectedResourceMetadata, } from './oauth.js' import { ScopePermissions, ScopeMissingError } from './scope.js' ``` **Step 2: Add OAuth metadata handlers** ```javascript 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** ```javascript async handleOAuthPar(request, url) { // Verify DPoP, validate client, store request, return request_uri } ``` **Step 4: Add authorize handler** (GET shows consent, POST processes) ```javascript async handleOAuthAuthorize(request, url) { if (request.method === 'GET') { // Render consent page } else { // Process consent, issue code, redirect } } ``` **Step 5: Add token handler** ```javascript async handleOAuthToken(request, url) { // Handle authorization_code and refresh_token grants } ``` **Step 6: Add revoke handler** ```javascript async handleOAuthRevoke(request) { // Revoke token } ``` **Step 7: Add all OAuth routes** ```javascript '/.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** ```javascript 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: ```javascript 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** ```bash 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** ```javascript 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()** ```javascript 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** ```bash 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** ```javascript 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** ```javascript 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** ```javascript handleRoot() { return new Response(`ATProto PDS v${VERSION}`, { headers: { 'Content-Type': 'text/plain' } }) } ``` **Step 4: Add routes** ```javascript '/': { handler: PersonalDataServer.prototype.handleRoot }, '/init': { method: 'POST', handler: PersonalDataServer.prototype.handleInit }, '/.well-known/atproto-did': { handler: PersonalDataServer.prototype.handleAtprotoDid }, ``` **Step 5: Commit** ```bash 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** ```javascript 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** ```bash 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** ```javascript 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** ```bash 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** ```json { "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** ```bash 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** ```bash npm test ``` **Step 2: Run Cloudflare E2E tests** ```bash npm run test:e2e ``` **Step 3: Run Node E2E tests** ```bash 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** ```bash 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.js` deleted or reduced to thin wrapper