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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
738 lines · 22.3 KB Raw History

SharedStoragePort Implementation Plan

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

Goal: Split StoragePort into ActorStoragePort (per-DID data) and SharedStoragePort (global data) to prepare for multi-tenant architecture.

Architecture: Create a new SharedStoragePort interface for OAuth requests, OAuth tokens, DPoP JTIs, and actor/handle registry. Implement for both Node.js (SQLite) and Cloudflare (KV). Update PDS to accept both ports.

Tech Stack: TypeScript/JSDoc, better-sqlite3, Cloudflare KV, Vitest


Task 1: Define SharedStoragePort Interface

Files:

  • Modify: packages/core/ports.js

Step 1: Add SharedStoragePort typedef

Add after the existing StoragePort typedef:

/**
 * @typedef {Object} ActorRecord
 * @property {string} did
 * @property {string|null} handle
 * @property {number} createdAt
 */

/**
 * @typedef {Object} SharedStoragePort
 *
 * // Actor/handle registry
 * @property {(did: string) => Promise<ActorRecord|null>} getActor
 * @property {(handle: string) => Promise<string|null>} resolveHandle - Returns DID or null
 * @property {(did: string, handle: string|null, createdAt: number) => Promise<void>} putActor
 * @property {(did: string) => Promise<void>} deleteActor
 *
 * // OAuth requests
 * @property {(requestUri: string) => Promise<Object|null>} getOAuthRequest
 * @property {(requestUri: string, data: Object, expiresAt: number) => Promise<void>} putOAuthRequest
 * @property {(requestUri: string) => Promise<void>} deleteOAuthRequest
 *
 * // OAuth tokens
 * @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<Object[]>} listOAuthTokensByDid
 *
 * // DPoP jti tracking
 * @property {(jti: string, expiresAt: number) => Promise<boolean>} checkAndStoreDpopJti
 * @property {() => Promise<void>} cleanupExpiredDpopJtis
 */

Step 2: Rename StoragePort to ActorStoragePort

Remove OAuth and DPoP methods from the existing StoragePort, rename to ActorStoragePort:

/**
 * @typedef {Object} ActorStoragePort
 * @property {(uri: string) => Promise<{cid: string, value: Uint8Array}|null>} getRecord
 * @property {(collection: string, cursor: string|null, limit: number) => Promise<{records: RecordResult[], cursor: string|null}>} listRecords
 * @property {() => Promise<Array<{key: string, cid: string}>>} listAllRecords
 * @property {(uri: string, cid: string, collection: string, rkey: string, value: Uint8Array) => Promise<void>} putRecord
 * @property {(uri: string) => Promise<void>} deleteRecord
 * @property {(cid: string) => Promise<Uint8Array|null>} getBlock
 * @property {(cid: string, data: Uint8Array) => Promise<void>} putBlock
 * @property {() => Promise<{seq: number, cid: string, rev: string}|null>} getLatestCommit
 * @property {(seq: number, cid: string, rev: string, prev: string|null) => Promise<void>} putCommit
 * @property {(cursor: number|null, limit: number) => Promise<{events: EventResult[], cursor: number|null}>} getEvents
 * @property {(seq: number, did: string, commitCid: string, evt: Uint8Array) => Promise<void>} putEvent
 * @property {(cid: string) => Promise<{mimeType: string, size: number, createdAt: number}|null>} getBlob
 * @property {(cid: string, mimeType: string, size: number) => Promise<void>} putBlob
 * @property {(cid: string) => Promise<void>} deleteBlob
 * @property {(cursor: string|null, limit: number) => Promise<{cids: string[], cursor: string|null}>} listBlobs
 * @property {(olderThan: number) => Promise<string[]>} getOrphanedBlobs
 * @property {(blobCid: string, recordUri: string) => Promise<void>} linkBlobToRecord
 * @property {(recordUri: string) => Promise<void>} unlinkBlobsFromRecord
 * @property {() => Promise<string|null>} getDid
 * @property {(did: string) => Promise<void>} setDid
 * @property {() => Promise<Uint8Array|null>} getPrivateKey
 * @property {(key: Uint8Array) => Promise<void>} setPrivateKey
 * @property {() => Promise<string|null>} getHandle
 * @property {(handle: string) => Promise<void>} setHandle
 * @property {() => Promise<Object[]>} getPreferences
 * @property {(preferences: Object[]) => Promise<void>} setPreferences
 */

Step 3: Run typecheck

Run: pnpm typecheck Expected: PASS (or type errors to fix in later tasks)

Step 4: Commit

git add packages/core/ports.js
git commit -m "refactor: define SharedStoragePort and rename StoragePort to ActorStoragePort"

Task 2: Add SharedStorage to better-sqlite3

Files:

  • Modify: packages/storage-better-sqlite3/index.js

Step 1: Add the shared storage function to index.js

Add after the existing createBetterSqliteStorage function:

// Shared storage adapter for Node.js

/** @typedef {{did: string, handle: string|null, created_at: number}} ActorRow */
/** @typedef {{data: string, expires_at: number}} OAuthRequestRow */
/** @typedef {{data: string}} OAuthTokenRow */

/**
 * Create shared storage adapter for better-sqlite3 (Node.js)
 * @param {import('better-sqlite3').Database} db
 * @returns {import('@pdsjs/core/ports').SharedStoragePort}
 */
export function createSharedStorage(db) {
  // Initialize schema
  db.exec(`
    CREATE TABLE IF NOT EXISTS actors (
      did TEXT PRIMARY KEY,
      handle TEXT,
      created_at INTEGER NOT NULL
    );
    CREATE UNIQUE INDEX IF NOT EXISTS actors_handle_lower_idx ON actors(lower(handle));

    CREATE TABLE IF NOT EXISTS oauth_requests (
      request_uri TEXT PRIMARY KEY,
      data TEXT,
      expires_at INTEGER
    );
    CREATE INDEX IF NOT EXISTS oauth_requests_expires_idx ON oauth_requests(expires_at);

    CREATE TABLE IF NOT EXISTS oauth_tokens (
      token_id TEXT PRIMARY KEY,
      did TEXT NOT NULL,
      data TEXT
    );
    CREATE INDEX IF NOT EXISTS oauth_tokens_did_idx ON oauth_tokens(did);

    CREATE TABLE IF NOT EXISTS dpop_jtis (
      jti TEXT PRIMARY KEY,
      expires_at INTEGER
    );
    CREATE INDEX IF NOT EXISTS dpop_jtis_expires_idx ON dpop_jtis(expires_at);
  `);

  const stmts = {
    getActor: db.prepare('SELECT did, handle, created_at FROM actors WHERE did = ?'),
    resolveHandle: db.prepare('SELECT did FROM actors WHERE lower(handle) = lower(?)'),
    putActor: db.prepare('INSERT OR REPLACE INTO actors (did, handle, created_at) VALUES (?, ?, ?)'),
    deleteActor: db.prepare('DELETE FROM actors WHERE did = ?'),

    getOAuthRequest: db.prepare('SELECT data, expires_at FROM oauth_requests WHERE request_uri = ?'),
    putOAuthRequest: db.prepare('INSERT OR REPLACE INTO oauth_requests (request_uri, data, expires_at) VALUES (?, ?, ?)'),
    deleteOAuthRequest: db.prepare('DELETE FROM oauth_requests WHERE request_uri = ?'),

    getOAuthToken: db.prepare('SELECT data FROM oauth_tokens WHERE token_id = ?'),
    putOAuthToken: db.prepare('INSERT OR REPLACE INTO oauth_tokens (token_id, did, data) VALUES (?, ?, ?)'),
    deleteOAuthToken: db.prepare('DELETE FROM oauth_tokens WHERE token_id = ?'),
    listOAuthTokensByDid: db.prepare('SELECT data FROM oauth_tokens WHERE did = ?'),

    storeDpopJti: db.prepare('INSERT OR IGNORE INTO dpop_jtis (jti, expires_at) VALUES (?, ?)'),
    cleanupExpiredDpopJtis: db.prepare('DELETE FROM dpop_jtis WHERE expires_at < ?'),
  };

  return {
    async getActor(did) {
      const row = /** @type {ActorRow|undefined} */ (stmts.getActor.get(did));
      return row ? { did: row.did, handle: row.handle, createdAt: row.created_at } : null;
    },

    async resolveHandle(handle) {
      const row = /** @type {{did: string}|undefined} */ (stmts.resolveHandle.get(handle));
      return row ? row.did : null;
    },

    async putActor(did, handle, createdAt) {
      stmts.putActor.run(did, handle, createdAt);
    },

    async deleteActor(did) {
      stmts.deleteActor.run(did);
    },

    async getOAuthRequest(requestUri) {
      const row = /** @type {OAuthRequestRow|undefined} */ (stmts.getOAuthRequest.get(requestUri));
      if (!row) return null;
      if (row.expires_at < Date.now()) {
        stmts.deleteOAuthRequest.run(requestUri);
        return null;
      }
      return JSON.parse(row.data);
    },

    async putOAuthRequest(requestUri, data, expiresAt) {
      stmts.putOAuthRequest.run(requestUri, JSON.stringify(data), expiresAt);
    },

    async deleteOAuthRequest(requestUri) {
      stmts.deleteOAuthRequest.run(requestUri);
    },

    async getOAuthToken(tokenId) {
      const row = /** @type {OAuthTokenRow|undefined} */ (stmts.getOAuthToken.get(tokenId));
      return row ? JSON.parse(row.data) : null;
    },

    async putOAuthToken(tokenId, data) {
      const tokenData = /** @type {{did: string}} */ (data);
      stmts.putOAuthToken.run(tokenId, tokenData.did, JSON.stringify(data));
    },

    async deleteOAuthToken(tokenId) {
      stmts.deleteOAuthToken.run(tokenId);
    },

    async listOAuthTokensByDid(did) {
      const rows = /** @type {OAuthTokenRow[]} */ (stmts.listOAuthTokensByDid.all(did));
      return rows.map((r) => JSON.parse(r.data));
    },

    async checkAndStoreDpopJti(jti, expiresAt) {
      const result = stmts.storeDpopJti.run(jti, expiresAt);
      return result.changes > 0;
    },

    async cleanupExpiredDpopJtis() {
      stmts.cleanupExpiredDpopJtis.run(Date.now());
    },
  };
}

Step 2: Run typecheck

Run: pnpm typecheck Expected: PASS

Step 3: Commit

git add packages/storage-better-sqlite3/index.js
git commit -m "feat: add createSharedStorage to better-sqlite3 adapter"

Task 3: Add SharedStorage to Cloudflare adapter

Files:

  • Modify: packages/cloudflare/index.js

Step 1: Add the Cloudflare shared storage function to index.js

Add after the existing PdsDurableObject class:

// Shared storage adapter using Cloudflare KV

/**
 * @typedef {Object} KVNamespace
 * @property {(key: string, options?: {type?: string}) => Promise<any>} get
 * @property {(key: string, value: string, options?: {expirationTtl?: number, expiration?: number}) => Promise<void>} put
 * @property {(key: string) => Promise<void>} delete
 * @property {(options?: {prefix?: string}) => Promise<{keys: {name: string}[]}>} list
 */

/**
 * Create shared storage adapter for Cloudflare KV
 * @param {KVNamespace} kv
 * @returns {import('@pdsjs/core/ports').SharedStoragePort}
 */
export function createCloudflareSharedStorage(kv) {
  return {
    async getActor(did) {
      const data = await kv.get(`actor:${did}`, { type: 'json' });
      return data || null;
    },

    async resolveHandle(handle) {
      const did = await kv.get(`handle:${handle.toLowerCase()}`);
      return did || null;
    },

    async putActor(did, handle, createdAt) {
      // Store actor record
      await kv.put(`actor:${did}`, JSON.stringify({ did, handle, createdAt }));
      // Store handle -> did mapping for resolution
      if (handle) {
        await kv.put(`handle:${handle.toLowerCase()}`, did);
      }
    },

    async deleteActor(did) {
      const actor = await this.getActor(did);
      if (actor?.handle) {
        await kv.delete(`handle:${actor.handle.toLowerCase()}`);
      }
      await kv.delete(`actor:${did}`);
    },

    async getOAuthRequest(requestUri) {
      const data = await kv.get(`oauth_req:${requestUri}`, { type: 'json' });
      return data || null;
    },

    async putOAuthRequest(requestUri, data, expiresAt) {
      const ttl = Math.max(60, Math.floor((expiresAt - Date.now()) / 1000));
      await kv.put(`oauth_req:${requestUri}`, JSON.stringify(data), { expirationTtl: ttl });
    },

    async deleteOAuthRequest(requestUri) {
      await kv.delete(`oauth_req:${requestUri}`);
    },

    async getOAuthToken(tokenId) {
      const data = await kv.get(`oauth_tok:${tokenId}`, { type: 'json' });
      return data || null;
    },

    async putOAuthToken(tokenId, data) {
      const tokenData = /** @type {{did: string}} */ (data);
      await kv.put(`oauth_tok:${tokenId}`, JSON.stringify(data));
      // Also maintain a list of tokens per DID (for listOAuthTokensByDid)
      const listKey = `oauth_tok_list:${tokenData.did}`;
      const existing = await kv.get(listKey, { type: 'json' }) || [];
      if (!existing.includes(tokenId)) {
        existing.push(tokenId);
        await kv.put(listKey, JSON.stringify(existing));
      }
    },

    async deleteOAuthToken(tokenId) {
      const token = await this.getOAuthToken(tokenId);
      if (token?.did) {
        const listKey = `oauth_tok_list:${token.did}`;
        const existing = await kv.get(listKey, { type: 'json' }) || [];
        const updated = existing.filter((/** @type {string} */ id) => id !== tokenId);
        await kv.put(listKey, JSON.stringify(updated));
      }
      await kv.delete(`oauth_tok:${tokenId}`);
    },

    async listOAuthTokensByDid(did) {
      const listKey = `oauth_tok_list:${did}`;
      const tokenIds = await kv.get(listKey, { type: 'json' }) || [];
      const tokens = await Promise.all(
        tokenIds.map((/** @type {string} */ id) => this.getOAuthToken(id))
      );
      return tokens.filter(Boolean);
    },

    async checkAndStoreDpopJti(jti, expiresAt) {
      const existing = await kv.get(`dpop:${jti}`);
      if (existing) return false;
      const ttl = Math.max(60, Math.floor((expiresAt - Date.now()) / 1000));
      await kv.put(`dpop:${jti}`, '1', { expirationTtl: ttl });
      return true;
    },

    async cleanupExpiredDpopJtis() {
      // KV handles expiration automatically via expirationTtl
    },
  };
}

Step 2: Run typecheck

Run: pnpm typecheck Expected: PASS

Step 3: Commit

git add packages/cloudflare/index.js
git commit -m "feat: add createCloudflareSharedStorage to Cloudflare adapter"

Task 4: Remove shared methods from ActorStorage adapters

Files:

  • Modify: packages/storage-better-sqlite3/index.js
  • Modify: packages/cloudflare/index.js

Step 1: Remove shared tables and methods from better-sqlite3

In packages/storage-better-sqlite3/index.js:

  1. Remove from schema creation:

    • oauth_requests table
    • oauth_tokens table
    • dpop_jtis table
    • Related indexes
  2. Remove prepared statements:

    • getOAuthRequest, putOAuthRequest, deleteOAuthRequest
    • getOAuthToken, putOAuthToken, deleteOAuthToken, listOAuthTokensByDid
    • storeDpopJti, cleanupExpiredDpopJtis
  3. Remove methods from returned object:

    • getOAuthRequest, putOAuthRequest, deleteOAuthRequest
    • getOAuthToken, putOAuthToken, deleteOAuthToken, listOAuthTokensByDid
    • checkAndStoreDpopJti, cleanupExpiredDpopJtis

Step 2: Remove shared tables and methods from Cloudflare

In packages/cloudflare/index.js:

  1. Remove from schema creation in initSchema():

    • oauth_requests table
    • oauth_tokens table
    • dpop_jtis table
    • Related indexes
  2. Remove methods from storage object:

    • getOAuthRequest, putOAuthRequest, deleteOAuthRequest
    • getOAuthToken, putOAuthToken, deleteOAuthToken, listOAuthTokensByDid
    • checkAndStoreDpopJti, cleanupExpiredDpopJtis

Step 3: Run typecheck

Run: pnpm typecheck Expected: Errors about missing methods in PDS (fixed in next task)

Step 4: Commit

git add packages/storage-better-sqlite3/index.js packages/cloudflare/index.js
git commit -m "refactor: remove shared storage methods from ActorStorage adapters"

Task 5: Update PDS to use both storage ports

Files:

  • Modify: packages/core/pds.js

Step 1: Update constructor to accept sharedStorage

Find the constructor and update to accept both storages:

/**
 * @param {Object} opts
 * @param {import('./ports.js').ActorStoragePort} opts.actorStorage
 * @param {import('./ports.js').SharedStoragePort} opts.sharedStorage
 * @param {import('./ports.js').BlobPort} opts.blobs
 * @param {import('./ports.js').WebSocketPort} [opts.websocket]
 */
constructor(opts) {
  this.actorStorage = opts.actorStorage;
  this.sharedStorage = opts.sharedStorage;
  this.blobs = opts.blobs;
  this.websocket = opts.websocket;
}

Step 2: Update all storage references

Replace this.storage with appropriate port:

  • Actor data operations → this.actorStorage
  • OAuth request operations → this.sharedStorage
  • OAuth token operations → this.sharedStorage
  • DPoP operations → this.sharedStorage

Common patterns to find/replace:

  • this.storage.getOAuthRequest → this.sharedStorage.getOAuthRequest
  • this.storage.putOAuthRequest → this.sharedStorage.putOAuthRequest
  • this.storage.getOAuthToken → this.sharedStorage.getOAuthToken
  • this.storage.putOAuthToken → this.sharedStorage.putOAuthToken
  • this.storage.checkAndStoreDpopJti → this.sharedStorage.checkAndStoreDpopJti
  • this.storage.getRecord → this.actorStorage.getRecord
  • (etc. for all actor storage methods)

Step 3: Add resolveHandle support

Update the handleResolveHandle method to use sharedStorage:

async handleResolveHandle(handle) {
  // First check shared storage for handle -> DID mapping
  const did = await this.sharedStorage.resolveHandle(handle);
  if (did) {
    return { did };
  }
  // Fall back to checking if this is our handle
  const ourHandle = await this.actorStorage.getHandle();
  const ourDid = await this.actorStorage.getDid();
  if (ourHandle === handle && ourDid) {
    return { did: ourDid };
  }
  throw { error: 'InvalidRequest', message: 'Unable to resolve handle' };
}

Step 4: Run typecheck

Run: pnpm typecheck Expected: PASS (or errors in entry points, fixed next)

Step 5: Commit

git add packages/core/pds.js
git commit -m "refactor: update PDS to use actorStorage and sharedStorage ports"

Task 6: Update Node.js entry point

Files:

  • Modify: packages/node/index.js

Step 1: Import shared storage

import { createBetterSqliteStorage, createSharedStorage } from '@pdsjs/storage-better-sqlite3';

(Update the existing import to include createSharedStorage)

Step 2: Create both storage instances

Update the initialization to create both:

// Create actor storage (per-DID data)
const actorStorage = createBetterSqliteStorage(db);

// Create shared storage (global data) - can use same db for single-tenant
const sharedStorage = createSharedStorage(db);

// Create PDS with both storages
const pds = new PersonalDataServer({
  actorStorage,
  sharedStorage,
  blobs,
  websocket,
});

Step 3: Run typecheck

Run: pnpm typecheck Expected: PASS

Step 4: Commit

git add packages/node/index.js
git commit -m "refactor: update Node.js entry to use both storage ports"

Task 7: Update Cloudflare entry point

Files:

  • Modify: packages/cloudflare/index.js

Step 1: Update Durable Object to accept KV binding

The worker needs to pass KV namespace to the Durable Object. Update the fetch handler to extract KV:

// In the worker export
export default {
  async fetch(request, env) {
    // Get Durable Object stub
    const id = env.PDS.idFromName('pds');
    const stub = env.PDS.get(id);
    // Pass KV namespace via header or store in env
    return stub.fetch(request);
  }
}

Step 2: Create shared storage in DO constructor

import { createCloudflareSharedStorage } from './shared.js';

constructor(state, env) {
  this.state = state;
  this.env = env;
  // ... existing init ...

  // Create shared storage using KV
  this.sharedStorage = createCloudflareSharedStorage(env.SHARED_KV);
}

Step 3: Pass both storages to PDS

this.pds = new PersonalDataServer({
  actorStorage: storage,  // DO's SQLite storage
  sharedStorage: this.sharedStorage,  // KV storage
  blobs,
  websocket,
});

Step 4: Update wrangler.toml

Add KV namespace binding:

[[kv_namespaces]]
binding = "SHARED_KV"
id = "your-kv-namespace-id"

Step 5: Run typecheck

Run: pnpm typecheck Expected: PASS

Step 6: Commit

git add packages/cloudflare/ wrangler.toml
git commit -m "refactor: update Cloudflare entry to use KV for shared storage"

Task 8: Update unit tests

Files:

  • Modify: test/pds.test.js

Step 1: Update any unit tests that create storage mocks

If any tests create mock storage objects, update them to match the new ActorStoragePort interface (without OAuth/DPoP methods).

Step 2: Run unit tests

Run: pnpm test -- test/pds.test.js Expected: All 126 unit tests PASS

Step 3: Commit

git add test/pds.test.js
git commit -m "test: update unit tests for split storage ports"

Task 9: Run e2e tests and verify OAuth flow

Files:

  • None (verification only)

Step 1: Run e2e tests

Run: pnpm test -- test/e2e.test.js Expected: All 84 e2e tests PASS

Step 2: Verify OAuth endpoints work

The e2e tests cover:

  • OAuth authorization request flow (uses SharedStorage.putOAuthRequest)
  • Token exchange (uses SharedStorage.putOAuthToken)
  • DPoP proof validation (uses SharedStorage.checkAndStoreDpopJti)
  • Token refresh and revocation

If any OAuth tests fail, check that:

  1. SharedStorage is being passed correctly to PDS
  2. OAuth methods are calling this.sharedStorage not this.actorStorage

Step 3: Verify handle resolution works

Test handle resolution manually or check e2e test coverage:

  • com.atproto.identity.resolveHandle should use SharedStorage.resolveHandle

Task 10: Register actor on init

Files:

  • Modify: packages/core/pds.js

Step 1: Update handleInit to register actor in shared storage

In the handleInit method, after setting DID and handle in actor storage, also register in shared storage:

async handleInit(did, privateKeyHex, handle) {
  // ... existing validation and actor storage setup ...

  // Register actor in shared storage for handle resolution
  await this.sharedStorage.putActor(did, handle, Date.now());

  // ... rest of init ...
}

Step 2: Run all tests

Run: pnpm test Expected: All 210 tests PASS

Step 3: Commit

git add packages/core/pds.js
git commit -m "feat: register actor in shared storage on init"

Summary

After completing all tasks:

  • ActorStoragePort handles per-DID data (records, blocks, commits, blobs, preferences)
  • SharedStoragePort handles global data (actors, OAuth, DPoP)
  • Node.js uses SQLite for both (same file for now, separate later)
  • Cloudflare uses DO SQLite for actor, KV for shared
  • Handle resolution uses shared storage
  • Ready for multi-tenant when needed