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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1075 lines · 24.8 KB Raw History
# 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<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**

```javascript
/**
 * @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**

```javascript
/**
 * @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**

```javascript
/**
 * @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`:

```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<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**

```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