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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1416 lines · 37.6 KB Raw History
# PDS Core Separation Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Separate pure PDS logic from Cloudflare-specific code to enable full unit test coverage and future portability.

**Architecture:** Core library (`pds.js`) contains pure functions, route handlers, and ATProto logic with dependency injection for storage/auth. Thin Cloudflare adapter (`cloudflare.js`) implements repository and auth interfaces using Durable Objects, SQLite, and R2.

**Tech Stack:** JavaScript ES modules, Vitest for testing, Cloudflare Workers/Durable Objects/R2 for production.

---

### Task 1: Define Repository Interface

**Files:**
- Create: `src/repository.js`
- Test: `test/repository.test.js`

**Step 1: Write the interface test**

```javascript
// test/repository.test.js
import { describe, it, expect } from 'vitest';
import { MockRepository } from '../src/repository.js';

describe('MockRepository', () => {
  it('implements all required methods', () => {
    const repo = new MockRepository();
    expect(typeof repo.putBlock).toBe('function');
    expect(typeof repo.getBlock).toBe('function');
    expect(typeof repo.hasBlock).toBe('function');
    expect(typeof repo.indexRecord).toBe('function');
    expect(typeof repo.getRecord).toBe('function');
    expect(typeof repo.listRecords).toBe('function');
    expect(typeof repo.deleteRecord).toBe('function');
    expect(typeof repo.getLatestCommit).toBe('function');
    expect(typeof repo.saveCommit).toBe('function');
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- test/repository.test.js`
Expected: FAIL with "Cannot find module '../src/repository.js'"

**Step 3: Write minimal implementation**

```javascript
// src/repository.js
/**
 * Repository interface for PDS storage operations.
 * @typedef {Object} Repository
 * @property {(cid: string, data: Uint8Array) => Promise<void>} putBlock
 * @property {(cid: string) => Promise<Uint8Array|null>} getBlock
 * @property {(cid: string) => Promise<boolean>} hasBlock
 * @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise<void>} indexRecord
 * @property {(uri: string) => Promise<{cid: string, value: Uint8Array}|null>} getRecord
 * @property {(collection: string, options?: {limit?: number, cursor?: string, reverse?: boolean}) => Promise<{records: Array<{uri: string, cid: string, value: Uint8Array}>, cursor?: string}>} listRecords
 * @property {(uri: string) => Promise<void>} deleteRecord
 * @property {() => Promise<{cid: string, rev: string}|null>} getLatestCommit
 * @property {(cid: string, rev: string, prev: string|null) => Promise<number>} saveCommit
 */

/**
 * In-memory mock repository for testing.
 */
export class MockRepository {
  constructor() {
    this.blocks = new Map();
    this.records = new Map();
    this.commits = [];
  }

  async putBlock(cid, data) {
    this.blocks.set(cid, data);
  }

  async getBlock(cid) {
    return this.blocks.get(cid) || null;
  }

  async hasBlock(cid) {
    return this.blocks.has(cid);
  }

  async indexRecord(uri, cid, collection, rkey, value) {
    this.records.set(uri, { cid, collection, rkey, value });
  }

  async getRecord(uri) {
    const record = this.records.get(uri);
    return record ? { cid: record.cid, value: record.value } : null;
  }

  async listRecords(collection, options = {}) {
    const { limit = 50, cursor, reverse = false } = options;
    let records = [...this.records.entries()]
      .filter(([, r]) => r.collection === collection)
      .map(([uri, r]) => ({ uri, cid: r.cid, value: r.value }));

    if (reverse) records.reverse();
    if (cursor) {
      const idx = records.findIndex(r => r.uri === cursor);
      if (idx >= 0) records = records.slice(idx + 1);
    }

    return {
      records: records.slice(0, limit),
      cursor: records.length > limit ? records[limit - 1].uri : undefined,
    };
  }

  async deleteRecord(uri) {
    this.records.delete(uri);
  }

  async getLatestCommit() {
    if (this.commits.length === 0) return null;
    const last = this.commits[this.commits.length - 1];
    return { cid: last.cid, rev: last.rev };
  }

  async saveCommit(cid, rev, prev) {
    const seq = this.commits.length + 1;
    this.commits.push({ cid, rev, prev, seq });
    return seq;
  }
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- test/repository.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/repository.js test/repository.test.js
git commit -m "feat: add Repository interface and MockRepository"
```

---

### Task 2: Add MockRepository CRUD Tests

**Files:**
- Modify: `test/repository.test.js`

**Step 1: Write block storage tests**

Add to `test/repository.test.js`:

```javascript
describe('MockRepository block storage', () => {
  it('stores and retrieves blocks', async () => {
    const repo = new MockRepository();
    const data = new Uint8Array([1, 2, 3, 4]);

    await repo.putBlock('cid123', data);
    const retrieved = await repo.getBlock('cid123');

    expect(retrieved).toEqual(data);
  });

  it('returns null for missing blocks', async () => {
    const repo = new MockRepository();
    expect(await repo.getBlock('missing')).toBeNull();
  });

  it('checks block existence', async () => {
    const repo = new MockRepository();
    await repo.putBlock('cid123', new Uint8Array([1]));

    expect(await repo.hasBlock('cid123')).toBe(true);
    expect(await repo.hasBlock('missing')).toBe(false);
  });
});
```

**Step 2: Run tests**

Run: `npm test -- test/repository.test.js`
Expected: PASS

**Step 3: Write record indexing tests**

Add to `test/repository.test.js`:

```javascript
describe('MockRepository record indexing', () => {
  it('indexes and retrieves records', async () => {
    const repo = new MockRepository();
    const value = new Uint8Array([5, 6, 7]);

    await repo.indexRecord('at://did:plc:test/app.bsky.feed.post/abc', 'cid456', 'app.bsky.feed.post', 'abc', value);
    const record = await repo.getRecord('at://did:plc:test/app.bsky.feed.post/abc');

    expect(record).toEqual({ cid: 'cid456', value });
  });

  it('returns null for missing records', async () => {
    const repo = new MockRepository();
    expect(await repo.getRecord('at://missing')).toBeNull();
  });

  it('lists records by collection', async () => {
    const repo = new MockRepository();
    await repo.indexRecord('at://did/col1/a', 'c1', 'col1', 'a', new Uint8Array([1]));
    await repo.indexRecord('at://did/col2/b', 'c2', 'col2', 'b', new Uint8Array([2]));
    await repo.indexRecord('at://did/col1/c', 'c3', 'col1', 'c', new Uint8Array([3]));

    const result = await repo.listRecords('col1');
    expect(result.records.length).toBe(2);
    expect(result.records.every(r => r.uri.includes('col1'))).toBe(true);
  });

  it('deletes records', async () => {
    const repo = new MockRepository();
    await repo.indexRecord('at://did/col/a', 'c1', 'col', 'a', new Uint8Array([1]));

    await repo.deleteRecord('at://did/col/a');
    expect(await repo.getRecord('at://did/col/a')).toBeNull();
  });
});
```

**Step 4: Run tests**

Run: `npm test -- test/repository.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add test/repository.test.js
git commit -m "test: add MockRepository CRUD tests"
```

---

### Task 3: Add Commit Tracking Tests

**Files:**
- Modify: `test/repository.test.js`

**Step 1: Write commit tracking tests**

Add to `test/repository.test.js`:

```javascript
describe('MockRepository commit tracking', () => {
  it('returns null when no commits exist', async () => {
    const repo = new MockRepository();
    expect(await repo.getLatestCommit()).toBeNull();
  });

  it('saves and retrieves commits', async () => {
    const repo = new MockRepository();

    const seq1 = await repo.saveCommit('cid1', 'rev1', null);
    expect(seq1).toBe(1);

    const seq2 = await repo.saveCommit('cid2', 'rev2', 'cid1');
    expect(seq2).toBe(2);

    const latest = await repo.getLatestCommit();
    expect(latest).toEqual({ cid: 'cid2', rev: 'rev2' });
  });
});
```

**Step 2: Run tests**

Run: `npm test -- test/repository.test.js`
Expected: PASS

**Step 3: Commit**

```bash
git add test/repository.test.js
git commit -m "test: add commit tracking tests"
```

---

### Task 4: Define BlobStore Interface

**Files:**
- Modify: `src/repository.js`
- Modify: `test/repository.test.js`

**Step 1: Write BlobStore interface tests**

Add to `test/repository.test.js`:

```javascript
import { MockRepository, MockBlobStore } from '../src/repository.js';

describe('MockBlobStore', () => {
  it('implements all required methods', () => {
    const store = new MockBlobStore();
    expect(typeof store.putBlob).toBe('function');
    expect(typeof store.getBlob).toBe('function');
    expect(typeof store.deleteBlob).toBe('function');
    expect(typeof store.hasBlob).toBe('function');
  });

  it('stores and retrieves blobs', async () => {
    const store = new MockBlobStore();
    const data = new Uint8Array([1, 2, 3, 4, 5]);

    await store.putBlob('blobcid123', data, 'image/png');
    const result = await store.getBlob('blobcid123');

    expect(result).toEqual({ data, mimeType: 'image/png' });
  });

  it('returns null for missing blobs', async () => {
    const store = new MockBlobStore();
    expect(await store.getBlob('missing')).toBeNull();
  });

  it('deletes blobs', async () => {
    const store = new MockBlobStore();
    await store.putBlob('blobcid', new Uint8Array([1]), 'text/plain');

    await store.deleteBlob('blobcid');
    expect(await store.getBlob('blobcid')).toBeNull();
  });

  it('checks blob existence', async () => {
    const store = new MockBlobStore();
    await store.putBlob('exists', new Uint8Array([1]), 'text/plain');

    expect(await store.hasBlob('exists')).toBe(true);
    expect(await store.hasBlob('missing')).toBe(false);
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- test/repository.test.js`
Expected: FAIL with "MockBlobStore is not exported"

**Step 3: Implement MockBlobStore**

Add to `src/repository.js`:

```javascript
/**
 * BlobStore interface for binary large object storage.
 * @typedef {Object} BlobStore
 * @property {(cid: string, data: Uint8Array, mimeType: string) => Promise<void>} putBlob
 * @property {(cid: string) => Promise<{data: Uint8Array, mimeType: string}|null>} getBlob
 * @property {(cid: string) => Promise<void>} deleteBlob
 * @property {(cid: string) => Promise<boolean>} hasBlob
 */

/**
 * In-memory mock blob store for testing.
 */
export class MockBlobStore {
  constructor() {
    this.blobs = new Map();
  }

  async putBlob(cid, data, mimeType) {
    this.blobs.set(cid, { data, mimeType });
  }

  async getBlob(cid) {
    return this.blobs.get(cid) || null;
  }

  async deleteBlob(cid) {
    this.blobs.delete(cid);
  }

  async hasBlob(cid) {
    return this.blobs.has(cid);
  }
}
```

**Step 4: Run tests**

Run: `npm test -- test/repository.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/repository.js test/repository.test.js
git commit -m "feat: add BlobStore interface and MockBlobStore"
```

---

### Task 5: Define AuthProvider Interface

**Files:**
- Create: `src/auth.js`
- Create: `test/auth.test.js`

**Step 1: Write AuthProvider interface tests**

```javascript
// test/auth.test.js
import { describe, it, expect } from 'vitest';
import { MockAuthProvider } from '../src/auth.js';

describe('MockAuthProvider', () => {
  it('implements all required methods', () => {
    const auth = new MockAuthProvider();
    expect(typeof auth.createSession).toBe('function');
    expect(typeof auth.getSession).toBe('function');
    expect(typeof auth.deleteSession).toBe('function');
    expect(typeof auth.storeAuthorizationRequest).toBe('function');
    expect(typeof auth.getAuthorizationRequest).toBe('function');
    expect(typeof auth.storeToken).toBe('function');
    expect(typeof auth.getToken).toBe('function');
    expect(typeof auth.revokeToken).toBe('function');
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- test/auth.test.js`
Expected: FAIL with "Cannot find module '../src/auth.js'"

**Step 3: Write minimal implementation**

```javascript
// src/auth.js
/**
 * AuthProvider interface for session and token management.
 * @typedef {Object} AuthProvider
 * @property {(did: string) => Promise<{accessJwt: string, refreshJwt: string}>} createSession
 * @property {(accessJwt: string) => Promise<{did: string}|null>} getSession
 * @property {(refreshJwt: string) => Promise<void>} deleteSession
 * @property {(id: string, request: Object) => Promise<void>} storeAuthorizationRequest
 * @property {(id: string) => Promise<Object|null>} getAuthorizationRequest
 * @property {(tokenId: string, token: Object) => Promise<void>} storeToken
 * @property {(tokenId: string) => Promise<Object|null>} getToken
 * @property {(tokenId: string) => Promise<void>} revokeToken
 */

/**
 * In-memory mock auth provider for testing.
 */
export class MockAuthProvider {
  constructor(jwtSecret = 'test-secret') {
    this.jwtSecret = jwtSecret;
    this.sessions = new Map();
    this.authRequests = new Map();
    this.tokens = new Map();
  }

  async createSession(did) {
    const accessJwt = `access-${did}-${Date.now()}`;
    const refreshJwt = `refresh-${did}-${Date.now()}`;
    this.sessions.set(accessJwt, { did, refreshJwt });
    return { accessJwt, refreshJwt };
  }

  async getSession(accessJwt) {
    const session = this.sessions.get(accessJwt);
    return session ? { did: session.did } : null;
  }

  async deleteSession(refreshJwt) {
    for (const [access, session] of this.sessions) {
      if (session.refreshJwt === refreshJwt) {
        this.sessions.delete(access);
        break;
      }
    }
  }

  async storeAuthorizationRequest(id, request) {
    this.authRequests.set(id, request);
  }

  async getAuthorizationRequest(id) {
    return this.authRequests.get(id) || null;
  }

  async storeToken(tokenId, token) {
    this.tokens.set(tokenId, token);
  }

  async getToken(tokenId) {
    return this.tokens.get(tokenId) || null;
  }

  async revokeToken(tokenId) {
    this.tokens.delete(tokenId);
  }
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- test/auth.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/auth.js test/auth.test.js
git commit -m "feat: add AuthProvider interface and MockAuthProvider"
```

---

### Task 6: Add MockAuthProvider Functional Tests

**Files:**
- Modify: `test/auth.test.js`

**Step 1: Write session management tests**

Add to `test/auth.test.js`:

```javascript
describe('MockAuthProvider sessions', () => {
  it('creates and retrieves sessions', async () => {
    const auth = new MockAuthProvider();

    const session = await auth.createSession('did:plc:test123');
    expect(session.accessJwt).toBeDefined();
    expect(session.refreshJwt).toBeDefined();

    const retrieved = await auth.getSession(session.accessJwt);
    expect(retrieved).toEqual({ did: 'did:plc:test123' });
  });

  it('returns null for invalid session', async () => {
    const auth = new MockAuthProvider();
    expect(await auth.getSession('invalid-token')).toBeNull();
  });

  it('deletes sessions by refresh token', async () => {
    const auth = new MockAuthProvider();
    const session = await auth.createSession('did:plc:test');

    await auth.deleteSession(session.refreshJwt);
    expect(await auth.getSession(session.accessJwt)).toBeNull();
  });
});
```

**Step 2: Run tests**

Run: `npm test -- test/auth.test.js`
Expected: PASS

**Step 3: Write OAuth storage tests**

Add to `test/auth.test.js`:

```javascript
describe('MockAuthProvider OAuth', () => {
  it('stores and retrieves authorization requests', async () => {
    const auth = new MockAuthProvider();
    const request = {
      clientId: 'https://example.com',
      scope: 'atproto',
      codeChallenge: 'abc123',
    };

    await auth.storeAuthorizationRequest('req-id-1', request);
    const retrieved = await auth.getAuthorizationRequest('req-id-1');

    expect(retrieved).toEqual(request);
  });

  it('returns null for missing auth requests', async () => {
    const auth = new MockAuthProvider();
    expect(await auth.getAuthorizationRequest('missing')).toBeNull();
  });

  it('stores, retrieves, and revokes tokens', async () => {
    const auth = new MockAuthProvider();
    const token = {
      did: 'did:plc:test',
      clientId: 'https://example.com',
      scope: 'atproto',
    };

    await auth.storeToken('token-id-1', token);
    expect(await auth.getToken('token-id-1')).toEqual(token);

    await auth.revokeToken('token-id-1');
    expect(await auth.getToken('token-id-1')).toBeNull();
  });
});
```

**Step 4: Run tests**

Run: `npm test -- test/auth.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add test/auth.test.js
git commit -m "test: add MockAuthProvider functional tests"
```

---

### Task 7: Create PDS Core Entry Point

**Files:**
- Modify: `src/pds.js` (add exports, create handler factory)
- Create: `test/pds-core.test.js`

**Step 1: Write core entry point test**

```javascript
// test/pds-core.test.js
import { describe, it, expect } from 'vitest';
import { createPdsHandler } from '../src/pds.js';
import { MockRepository, MockBlobStore } from '../src/repository.js';
import { MockAuthProvider } from '../src/auth.js';

describe('createPdsHandler', () => {
  it('returns a request handler function', () => {
    const handler = createPdsHandler({
      did: 'did:plc:test',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    expect(typeof handler).toBe('function');
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- test/pds-core.test.js`
Expected: FAIL with "createPdsHandler is not exported"

**Step 3: Add createPdsHandler export to pds.js**

Add near the end of `src/pds.js` (before `export default`):

```javascript
/**
 * Creates a PDS request handler with injected dependencies.
 * @param {Object} config
 * @param {string} config.did - The DID for this PDS
 * @param {string} config.hostname - The hostname for this PDS
 * @param {Repository} config.repository - Storage backend
 * @param {BlobStore} config.blobStore - Blob storage backend
 * @param {AuthProvider} config.authProvider - Auth/session backend
 * @returns {(request: Request) => Promise<Response>}
 */
export function createPdsHandler(config) {
  const { did, hostname, repository, blobStore, authProvider } = config;

  return async function handleRequest(request) {
    // TODO: Wire up to route handlers with injected dependencies
    return new Response('PDS handler stub', { status: 501 });
  };
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- test/pds-core.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/pds.js test/pds-core.test.js
git commit -m "feat: add createPdsHandler factory function"
```

---

### Task 8: Extract Pure Functions Module

**Files:**
- Create: `src/atproto.js` (pure ATProto utilities)
- Modify: `src/pds.js` (re-export from atproto.js)
- Create: `test/atproto.test.js`

**Step 1: Identify pure functions to extract**

The following pure functions from `pds.js` (lines 324-1715) should move to `atproto.js`:
- Encoding: `bytesToHex`, `hexToBytes`, `base32Encode`, `base32Decode`, `base64UrlEncode`, `base64UrlDecode`
- CBOR: `cborEncode`, `cborEncodeDagCbor`, `cborDecode`, `varint`
- CID: `createCid`, `createBlobCid`, `cidToString`, `cidToBytes`
- TID: `createTid`
- Crypto: `sign`, `generateKeyPair`, `importPrivateKey`, `computeJwkThumbprint`
- JWT: `createAccessJwt`, `createRefreshJwt`, `verifyAccessJwt`, `verifyRefreshJwt`, `createServiceJwt`
- MST: `getKeyDepth`
- CAR: `buildCarFile`
- MIME: `sniffMimeType`, `findBlobRefs`

**Step 2: Create atproto.js with encoding utilities**

```javascript
// src/atproto.js
// Pure ATProto utilities - no platform dependencies

// Re-export from pds.js for now (will move implementations later)
export {
  bytesToHex,
  hexToBytes,
  base32Encode,
  base32Decode,
  base64UrlEncode,
  base64UrlDecode,
  cborEncode,
  cborEncodeDagCbor,
  cborDecode,
  varint,
  createCid,
  createBlobCid,
  cidToString,
  cidToBytes,
  createTid,
  sign,
  generateKeyPair,
  importPrivateKey,
  computeJwkThumbprint,
  createAccessJwt,
  createRefreshJwt,
  verifyAccessJwt,
  verifyRefreshJwt,
  createServiceJwt,
  getKeyDepth,
  buildCarFile,
  sniffMimeType,
  findBlobRefs,
} from './pds.js';
```

**Step 3: Write test for atproto exports**

```javascript
// test/atproto.test.js
import { describe, it, expect } from 'vitest';
import * as atproto from '../src/atproto.js';

describe('atproto exports', () => {
  it('exports encoding utilities', () => {
    expect(typeof atproto.bytesToHex).toBe('function');
    expect(typeof atproto.hexToBytes).toBe('function');
    expect(typeof atproto.base32Encode).toBe('function');
    expect(typeof atproto.base32Decode).toBe('function');
    expect(typeof atproto.base64UrlEncode).toBe('function');
    expect(typeof atproto.base64UrlDecode).toBe('function');
  });

  it('exports CBOR utilities', () => {
    expect(typeof atproto.cborEncode).toBe('function');
    expect(typeof atproto.cborEncodeDagCbor).toBe('function');
    expect(typeof atproto.cborDecode).toBe('function');
  });

  it('exports CID utilities', () => {
    expect(typeof atproto.createCid).toBe('function');
    expect(typeof atproto.createBlobCid).toBe('function');
    expect(typeof atproto.cidToString).toBe('function');
    expect(typeof atproto.cidToBytes).toBe('function');
  });

  it('exports crypto utilities', () => {
    expect(typeof atproto.sign).toBe('function');
    expect(typeof atproto.generateKeyPair).toBe('function');
    expect(typeof atproto.importPrivateKey).toBe('function');
  });

  it('exports JWT utilities', () => {
    expect(typeof atproto.createAccessJwt).toBe('function');
    expect(typeof atproto.verifyAccessJwt).toBe('function');
    expect(typeof atproto.createServiceJwt).toBe('function');
  });
});
```

**Step 4: Run tests**

Run: `npm test -- test/atproto.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/atproto.js test/atproto.test.js
git commit -m "feat: create atproto.js pure utilities module"
```

---

### Task 9: Create Cloudflare Adapter Skeleton

**Files:**
- Create: `src/cloudflare.js`
- Modify: `wrangler.toml` (update main entry point)

**Step 1: Create cloudflare.js adapter**

```javascript
// src/cloudflare.js
// Cloudflare-specific adapter implementing Repository, BlobStore, and AuthProvider
import { PersonalDataServer } from './pds.js';

// Re-export the Durable Object class for Cloudflare
export { PersonalDataServer };

// Re-export the default fetch handler
export { default } from './pds.js';
```

**Step 2: Verify syntax**

Run: `node --check src/cloudflare.js`
Expected: No output (success)

**Step 3: Update wrangler.toml to use cloudflare.js**

Change:
```toml
main = "src/pds.js"
```

To:
```toml
main = "src/cloudflare.js"
```

**Step 4: Run e2e tests to verify nothing broke**

Run: `npm run test:e2e`
Expected: All tests pass

**Step 5: Commit**

```bash
git add src/cloudflare.js wrangler.toml
git commit -m "feat: create cloudflare.js adapter entry point"
```

---

### Task 10: Implement CloudflareRepository

**Files:**
- Modify: `src/cloudflare.js`
- Create: `test/cloudflare-repository.test.js`

**Step 1: Write CloudflareRepository test**

```javascript
// test/cloudflare-repository.test.js
import { describe, it, expect } from 'vitest';

// Note: This test verifies interface compliance only
// Full integration testing happens in e2e tests

describe('CloudflareRepository interface', () => {
  it('should be tested via e2e tests with real Durable Objects', () => {
    // CloudflareRepository requires actual Cloudflare runtime
    // This is a placeholder to document that testing strategy
    expect(true).toBe(true);
  });
});
```

**Step 2: Implement CloudflareRepository in cloudflare.js**

Add to `src/cloudflare.js`:

```javascript
/**
 * Repository implementation backed by Durable Object SQLite storage.
 */
export class CloudflareRepository {
  /**
   * @param {DurableObjectState['storage']['sql']} sql
   */
  constructor(sql) {
    this.sql = sql;
  }

  async putBlock(cid, data) {
    this.sql.exec(
      'INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)',
      cid,
      data
    );
  }

  async getBlock(cid) {
    const row = this.sql.exec('SELECT data FROM blocks WHERE cid = ?', cid).one();
    return row ? new Uint8Array(row.data) : null;
  }

  async hasBlock(cid) {
    const row = this.sql.exec('SELECT 1 FROM blocks WHERE cid = ?', cid).one();
    return row !== null;
  }

  async indexRecord(uri, cid, collection, rkey, value) {
    this.sql.exec(
      'INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)',
      uri, cid, collection, rkey, value
    );
  }

  async getRecord(uri) {
    const row = this.sql.exec('SELECT cid, value FROM records WHERE uri = ?', uri).one();
    return row ? { cid: row.cid, value: new Uint8Array(row.value) } : null;
  }

  async listRecords(collection, options = {}) {
    const { limit = 50, cursor, reverse = false } = options;
    const order = reverse ? 'DESC' : 'ASC';
    let query = `SELECT uri, cid, value FROM records WHERE collection = ? ORDER BY rkey ${order} LIMIT ?`;
    const params = [collection, limit + 1];

    if (cursor) {
      query = `SELECT uri, cid, value FROM records WHERE collection = ? AND rkey ${reverse ? '<' : '>'} ? ORDER BY rkey ${order} LIMIT ?`;
      params.splice(1, 0, cursor);
    }

    const rows = [...this.sql.exec(query, ...params)];
    const hasMore = rows.length > limit;
    const records = rows.slice(0, limit).map(r => ({
      uri: r.uri,
      cid: r.cid,
      value: new Uint8Array(r.value),
    }));

    return {
      records,
      cursor: hasMore ? records[records.length - 1].uri.split('/').pop() : undefined,
    };
  }

  async deleteRecord(uri) {
    this.sql.exec('DELETE FROM records WHERE uri = ?', uri);
  }

  async getLatestCommit() {
    const row = this.sql.exec('SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1').one();
    return row ? { cid: row.cid, rev: row.rev } : null;
  }

  async saveCommit(cid, rev, prev) {
    this.sql.exec(
      'INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)',
      cid, rev, prev
    );
    return this.sql.exec('SELECT last_insert_rowid() as seq').one().seq;
  }
}
```

**Step 3: Run tests**

Run: `npm test`
Expected: All tests pass

**Step 4: Run e2e tests to verify integration**

Run: `npm run test:e2e`
Expected: All tests pass

**Step 5: Commit**

```bash
git add src/cloudflare.js test/cloudflare-repository.test.js
git commit -m "feat: implement CloudflareRepository"
```

---

### Task 11: Implement CloudflareBlobStore

**Files:**
- Modify: `src/cloudflare.js`

**Step 1: Implement CloudflareBlobStore**

Add to `src/cloudflare.js`:

```javascript
/**
 * BlobStore implementation backed by Cloudflare R2.
 */
export class CloudflareBlobStore {
  /**
   * @param {R2Bucket} bucket
   * @param {DurableObjectState['storage']['sql']} sql - For blob metadata
   */
  constructor(bucket, sql) {
    this.bucket = bucket;
    this.sql = sql;
  }

  async putBlob(cid, data, mimeType) {
    await this.bucket.put(cid, data, {
      httpMetadata: { contentType: mimeType },
    });
    this.sql.exec(
      'INSERT OR REPLACE INTO blobs (cid, mime_type, size, created_at) VALUES (?, ?, ?, ?)',
      cid, mimeType, data.length, new Date().toISOString()
    );
  }

  async getBlob(cid) {
    const object = await this.bucket.get(cid);
    if (!object) return null;

    const data = new Uint8Array(await object.arrayBuffer());
    const mimeType = object.httpMetadata?.contentType || 'application/octet-stream';
    return { data, mimeType };
  }

  async deleteBlob(cid) {
    await this.bucket.delete(cid);
    this.sql.exec('DELETE FROM blobs WHERE cid = ?', cid);
  }

  async hasBlob(cid) {
    const head = await this.bucket.head(cid);
    return head !== null;
  }
}
```

**Step 2: Verify syntax**

Run: `node --check src/cloudflare.js`
Expected: No output (success)

**Step 3: Run e2e tests**

Run: `npm run test:e2e`
Expected: All tests pass

**Step 4: Commit**

```bash
git add src/cloudflare.js
git commit -m "feat: implement CloudflareBlobStore"
```

---

### Task 12: Implement CloudflareAuthProvider

**Files:**
- Modify: `src/cloudflare.js`

**Step 1: Implement CloudflareAuthProvider**

Add to `src/cloudflare.js`:

```javascript
import { createAccessJwt, createRefreshJwt, verifyAccessJwt, verifyRefreshJwt } from './pds.js';

/**
 * AuthProvider implementation backed by Durable Object SQLite.
 */
export class CloudflareAuthProvider {
  /**
   * @param {DurableObjectState['storage']['sql']} sql
   * @param {string} jwtSecret
   */
  constructor(sql, jwtSecret) {
    this.sql = sql;
    this.jwtSecret = jwtSecret;
  }

  async createSession(did) {
    const accessJwt = await createAccessJwt(did, this.jwtSecret);
    const refreshJwt = await createRefreshJwt(did, this.jwtSecret);
    return { accessJwt, refreshJwt };
  }

  async getSession(accessJwt) {
    try {
      const payload = await verifyAccessJwt(accessJwt, this.jwtSecret);
      return { did: payload.sub };
    } catch {
      return null;
    }
  }

  async deleteSession(refreshJwt) {
    // Sessions are stateless JWTs - nothing to delete
    // Could add to a revocation list if needed
  }

  async storeAuthorizationRequest(id, request) {
    this.sql.exec(
      `INSERT INTO authorization_requests
       (id, client_id, client_metadata, parameters, code_challenge, code_challenge_method, dpop_jkt, expires_at, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      id,
      request.clientId,
      JSON.stringify(request.clientMetadata || {}),
      JSON.stringify(request.parameters || {}),
      request.codeChallenge || null,
      request.codeChallengeMethod || null,
      request.dpopJkt || null,
      request.expiresAt || new Date(Date.now() + 600000).toISOString(),
      new Date().toISOString()
    );
  }

  async getAuthorizationRequest(id) {
    const row = this.sql.exec('SELECT * FROM authorization_requests WHERE id = ?', id).one();
    if (!row) return null;

    return {
      id: row.id,
      clientId: row.client_id,
      clientMetadata: JSON.parse(row.client_metadata),
      parameters: JSON.parse(row.parameters),
      code: row.code,
      codeChallenge: row.code_challenge,
      codeChallengeMethod: row.code_challenge_method,
      dpopJkt: row.dpop_jkt,
      did: row.did,
      expiresAt: row.expires_at,
      createdAt: row.created_at,
    };
  }

  async storeToken(tokenId, token) {
    this.sql.exec(
      `INSERT INTO tokens
       (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      tokenId,
      token.did,
      token.clientId,
      token.scope || null,
      token.dpopJkt || null,
      token.expiresAt,
      token.refreshToken || null,
      new Date().toISOString(),
      new Date().toISOString()
    );
  }

  async getToken(tokenId) {
    const row = this.sql.exec('SELECT * FROM tokens WHERE token_id = ?', tokenId).one();
    if (!row) return null;

    return {
      tokenId: row.token_id,
      did: row.did,
      clientId: row.client_id,
      scope: row.scope,
      dpopJkt: row.dpop_jkt,
      expiresAt: row.expires_at,
      refreshToken: row.refresh_token,
    };
  }

  async revokeToken(tokenId) {
    this.sql.exec('DELETE FROM tokens WHERE token_id = ?', tokenId);
  }
}
```

**Step 2: Verify syntax**

Run: `node --check src/cloudflare.js`
Expected: No output (success)

**Step 3: Run e2e tests**

Run: `npm run test:e2e`
Expected: All tests pass

**Step 4: Commit**

```bash
git add src/cloudflare.js
git commit -m "feat: implement CloudflareAuthProvider"
```

---

### Task 13: Wire Up createPdsHandler with Route Handlers

**Files:**
- Modify: `src/pds.js`
- Modify: `test/pds-core.test.js`

**Step 1: Write integration test for handler routing**

Add to `test/pds-core.test.js`:

```javascript
describe('createPdsHandler routing', () => {
  it('handles .well-known/atproto-did', async () => {
    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/.well-known/atproto-did');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const text = await response.text();
    expect(text).toBe('did:plc:testuser123');
  });

  it('handles xrpc/com.atproto.server.describeServer', async () => {
    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: new MockRepository(),
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/xrpc/com.atproto.server.describeServer');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const json = await response.json();
    expect(json.did).toBe('did:plc:testuser123');
    expect(json.availableUserDomains).toContain('test.pds.example');
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `npm test -- test/pds-core.test.js`
Expected: FAIL (handler returns 501 stub)

**Step 3: Implement basic routing in createPdsHandler**

This is a larger refactor - the key insight is to make route handlers accept dependencies as parameters rather than accessing `this`. Start with the simplest routes:

Update `createPdsHandler` in `src/pds.js`:

```javascript
export function createPdsHandler(config) {
  const { did, hostname, repository, blobStore, authProvider } = config;

  return async function handleRequest(request) {
    const url = new URL(request.url);
    const path = url.pathname;

    // .well-known/atproto-did
    if (path === '/.well-known/atproto-did') {
      return new Response(did, {
        headers: { 'Content-Type': 'text/plain' },
      });
    }

    // com.atproto.server.describeServer
    if (path === '/xrpc/com.atproto.server.describeServer') {
      return Response.json({
        did,
        availableUserDomains: [hostname],
        inviteCodeRequired: false,
        phoneVerificationRequired: false,
        links: {},
      });
    }

    return new Response('Not Found', { status: 404 });
  };
}
```

**Step 4: Run tests**

Run: `npm test -- test/pds-core.test.js`
Expected: PASS

**Step 5: Commit**

```bash
git add src/pds.js test/pds-core.test.js
git commit -m "feat: wire up basic routes in createPdsHandler"
```

---

### Task 14: Add Core Handler Tests for Repo Operations

**Files:**
- Modify: `test/pds-core.test.js`

**Step 1: Write describeRepo test**

Add to `test/pds-core.test.js`:

```javascript
describe('createPdsHandler repo operations', () => {
  it('handles com.atproto.repo.describeRepo', async () => {
    const repo = new MockRepository();
    // Seed with a commit
    await repo.saveCommit('bafyreiabc', 'tid123', null);

    const handler = createPdsHandler({
      did: 'did:plc:testuser123',
      hostname: 'test.pds.example',
      repository: repo,
      blobStore: new MockBlobStore(),
      authProvider: new MockAuthProvider(),
    });

    const request = new Request('https://test.pds.example/xrpc/com.atproto.repo.describeRepo?repo=did:plc:testuser123');
    const response = await handler(request);

    expect(response.status).toBe(200);
    const json = await response.json();
    expect(json.did).toBe('did:plc:testuser123');
    expect(json.handle).toBe('test.pds.example');
  });
});
```

**Step 2: Run tests to see failure**

Run: `npm test -- test/pds-core.test.js`
Expected: FAIL (route not implemented)

**Step 3: This identifies the next route to implement**

Continue adding routes to `createPdsHandler` following the same pattern. Each route handler that currently uses `this.sql` or `this.env` needs to be refactored to use the injected `repository`, `blobStore`, and `authProvider`.

**Step 4: Commit progress**

```bash
git add test/pds-core.test.js
git commit -m "test: add repo operation tests for createPdsHandler"
```

---

### Task 15: Run Full Test Suite and Measure Coverage

**Files:**
- None (verification only)

**Step 1: Run unit tests with coverage**

Run: `npm run test:coverage`
Expected: Coverage report showing improvement from pure function tests

**Step 2: Run e2e tests**

Run: `npm run test:e2e`
Expected: All e2e tests pass

**Step 3: Review coverage gaps**

The coverage report will show which code paths still need tests. The goal is:
- Pure functions (atproto.js): High coverage via unit tests
- Route handlers (pds.js): Growing coverage via createPdsHandler tests
- Cloudflare adapters (cloudflare.js): Coverage via e2e tests

**Step 4: Document coverage baseline**

Record current coverage numbers for comparison as more handlers are migrated.

**Step 5: Commit**

```bash
git add .
git commit -m "chore: verify test coverage after core separation setup"
```

---

## Summary

This plan establishes the foundation for separating PDS core logic from Cloudflare:

1. **Tasks 1-6**: Create Repository, BlobStore, and AuthProvider interfaces with mock implementations
2. **Tasks 7-8**: Create `createPdsHandler` factory and extract pure utilities to `atproto.js`
3. **Tasks 9-12**: Create `cloudflare.js` adapter with Cloudflare-specific implementations
4. **Tasks 13-14**: Wire up route handlers to use dependency injection
5. **Task 15**: Verify coverage improvements

After completing these tasks, you'll have:
- A testable core (`createPdsHandler`) that works with mock dependencies
- Pure ATProto utilities (`atproto.js`) fully unit-testable
- Cloudflare adapter (`cloudflare.js`) as thin entry point
- Foundation for incrementally migrating remaining route handlers

Future work (not in this plan):
- Migrate all route handlers to use injected dependencies
- Move more logic from PersonalDataServer methods to pure functions
- Add more createPdsHandler tests for each route