chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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