A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

Cloudflare Durable Objects PDS Implementation Plan#

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

Goal: Build a minimal AT Protocol PDS on Cloudflare Durable Objects with zero dependencies.

Architecture: Each user gets their own Durable Object with SQLite storage. A router Worker maps DIDs to Objects. All crypto uses Web Crypto API (P-256 for signing, SHA-256 for hashing).

Tech Stack: Cloudflare Workers, Durable Objects, SQLite, Web Crypto API, no npm dependencies.


Task 1: Project Setup#

Files:

  • Create: package.json
  • Create: wrangler.toml
  • Create: src/pds.js

Step 1: Initialize package.json

{
  "name": "cloudflare-pds",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "test": "node test/run.js"
  }
}

Step 2: Create wrangler.toml

name = "atproto-pds"
main = "src/pds.js"
compatibility_date = "2024-01-01"

[[durable_objects.bindings]]
name = "PDS"
class_name = "PersonalDataServer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["PersonalDataServer"]

Step 3: Create minimal src/pds.js skeleton

export class PersonalDataServer {
  constructor(state, env) {
    this.state = state
    this.sql = state.storage.sql
  }

  async fetch(request) {
    return new Response('pds running', { status: 200 })
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url)
    const did = url.searchParams.get('did')

    if (!did) {
      return new Response('missing did param', { status: 400 })
    }

    const id = env.PDS.idFromName(did)
    const pds = env.PDS.get(id)
    return pds.fetch(request)
  }
}

Step 4: Verify it runs

Run: npx wrangler dev Test: curl "http://localhost:8787/?did=did:plc:test" Expected: pds running

Step 5: Commit

git init
git add -A
git commit -m "feat: initial project setup with Durable Object skeleton"

Task 2: CBOR Encoding#

Files:

  • Modify: src/pds.js

Implement minimal deterministic CBOR encoding. Only the types AT Protocol uses: maps, arrays, strings, bytes, integers, null, booleans.

Step 1: Add CBOR encoding function

Add to top of src/pds.js:

// === CBOR ENCODING ===
// Minimal deterministic CBOR (RFC 8949) - sorted keys, minimal integers

function cborEncode(value) {
  const parts = []

  function encode(val) {
    if (val === null) {
      parts.push(0xf6) // null
    } else if (val === true) {
      parts.push(0xf5) // true
    } else if (val === false) {
      parts.push(0xf4) // false
    } else if (typeof val === 'number') {
      encodeInteger(val)
    } else if (typeof val === 'string') {
      const bytes = new TextEncoder().encode(val)
      encodeHead(3, bytes.length) // major type 3 = text string
      parts.push(...bytes)
    } else if (val instanceof Uint8Array) {
      encodeHead(2, val.length) // major type 2 = byte string
      parts.push(...val)
    } else if (Array.isArray(val)) {
      encodeHead(4, val.length) // major type 4 = array
      for (const item of val) encode(item)
    } else if (typeof val === 'object') {
      // Sort keys for deterministic encoding
      const keys = Object.keys(val).sort()
      encodeHead(5, keys.length) // major type 5 = map
      for (const key of keys) {
        encode(key)
        encode(val[key])
      }
    }
  }

  function encodeHead(majorType, length) {
    const mt = majorType << 5
    if (length < 24) {
      parts.push(mt | length)
    } else if (length < 256) {
      parts.push(mt | 24, length)
    } else if (length < 65536) {
      parts.push(mt | 25, length >> 8, length & 0xff)
    } else if (length < 4294967296) {
      parts.push(mt | 26, (length >> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff)
    }
  }

  function encodeInteger(n) {
    if (n >= 0) {
      encodeHead(0, n) // major type 0 = unsigned int
    } else {
      encodeHead(1, -n - 1) // major type 1 = negative int
    }
  }

  encode(value)
  return new Uint8Array(parts)
}

Step 2: Add simple test endpoint

Modify the fetch handler temporarily:

async fetch(request) {
  const url = new URL(request.url)
  if (url.pathname === '/test/cbor') {
    const encoded = cborEncode({ hello: 'world', num: 42 })
    return new Response(encoded, {
      headers: { 'content-type': 'application/cbor' }
    })
  }
  return new Response('pds running', { status: 200 })
}

Step 3: Verify CBOR output

Run: npx wrangler dev Test: curl "http://localhost:8787/test/cbor?did=did:plc:test" | xxd Expected: Valid CBOR bytes (a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a)

Step 4: Commit

git add src/pds.js
git commit -m "feat: add deterministic CBOR encoding"

Task 3: CID Generation#

Files:

  • Modify: src/pds.js

Generate CIDs (Content Identifiers) using SHA-256 + multiformat encoding.

Step 1: Add CID utilities

Add after CBOR section:

// === CID GENERATION ===
// dag-cbor (0x71) + sha-256 (0x12) + 32 bytes

async function createCid(bytes) {
  const hash = await crypto.subtle.digest('SHA-256', bytes)
  const hashBytes = new Uint8Array(hash)

  // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256)
  // Multihash: hash-type(0x12) + length(0x20=32) + digest
  const cid = new Uint8Array(2 + 2 + 32)
  cid[0] = 0x01 // CIDv1
  cid[1] = 0x71 // dag-cbor codec
  cid[2] = 0x12 // sha-256
  cid[3] = 0x20 // 32 bytes
  cid.set(hashBytes, 4)

  return cid
}

function cidToString(cid) {
  // base32lower encoding for CIDv1
  return 'b' + base32Encode(cid)
}

function base32Encode(bytes) {
  const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
  let result = ''
  let bits = 0
  let value = 0

  for (const byte of bytes) {
    value = (value << 8) | byte
    bits += 8
    while (bits >= 5) {
      bits -= 5
      result += alphabet[(value >> bits) & 31]
    }
  }

  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 31]
  }

  return result
}

Step 2: Add test endpoint

if (url.pathname === '/test/cid') {
  const data = cborEncode({ test: 'data' })
  const cid = await createCid(data)
  return Response.json({ cid: cidToString(cid) })
}

Step 3: Verify CID generation

Run: npx wrangler dev Test: curl "http://localhost:8787/test/cid?did=did:plc:test" Expected: JSON with CID string starting with 'b'

Step 4: Commit

git add src/pds.js
git commit -m "feat: add CID generation with SHA-256"

Task 4: TID Generation#

Files:

  • Modify: src/pds.js

Generate TIDs (Timestamp IDs) for record keys and revisions.

Step 1: Add TID utilities

Add after CID section:

// === TID GENERATION ===
// Timestamp-based IDs: base32-sort encoded microseconds + clock ID

const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz'
let lastTimestamp = 0
let clockId = Math.floor(Math.random() * 1024)

function createTid() {
  let timestamp = Date.now() * 1000 // microseconds

  // Ensure monotonic
  if (timestamp <= lastTimestamp) {
    timestamp = lastTimestamp + 1
  }
  lastTimestamp = timestamp

  // 13 chars: 11 for timestamp (64 bits but only ~53 used), 2 for clock ID
  let tid = ''

  // Encode timestamp (high bits first for sortability)
  let ts = timestamp
  for (let i = 0; i < 11; i++) {
    tid = TID_CHARS[ts & 31] + tid
    ts = Math.floor(ts / 32)
  }

  // Append clock ID (2 chars)
  tid += TID_CHARS[(clockId >> 5) & 31]
  tid += TID_CHARS[clockId & 31]

  return tid
}

Step 2: Add test endpoint

if (url.pathname === '/test/tid') {
  const tids = [createTid(), createTid(), createTid()]
  return Response.json({ tids })
}

Step 3: Verify TIDs are monotonic

Run: npx wrangler dev Test: curl "http://localhost:8787/test/tid?did=did:plc:test" Expected: Three 13-char TIDs, each greater than the previous

Step 4: Commit

git add src/pds.js
git commit -m "feat: add TID generation for record keys"

Task 5: SQLite Schema#

Files:

  • Modify: src/pds.js

Initialize the database schema when the Durable Object starts.

Step 1: Add schema initialization

Modify the constructor:

export class PersonalDataServer {
  constructor(state, env) {
    this.state = state
    this.sql = state.storage.sql
    this.env = env

    // Initialize schema
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS blocks (
        cid TEXT PRIMARY KEY,
        data BLOB NOT NULL
      );

      CREATE TABLE IF NOT EXISTS records (
        uri TEXT PRIMARY KEY,
        cid TEXT NOT NULL,
        collection TEXT NOT NULL,
        rkey TEXT NOT NULL,
        value BLOB NOT NULL
      );

      CREATE TABLE IF NOT EXISTS commits (
        seq INTEGER PRIMARY KEY AUTOINCREMENT,
        cid TEXT NOT NULL,
        rev TEXT NOT NULL,
        prev TEXT
      );

      CREATE TABLE IF NOT EXISTS seq_events (
        seq INTEGER PRIMARY KEY AUTOINCREMENT,
        did TEXT NOT NULL,
        commit_cid TEXT NOT NULL,
        evt BLOB NOT NULL
      );

      CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey);
    `)
  }
  // ... rest of class
}

Step 2: Add test endpoint to verify schema

if (url.pathname === '/test/schema') {
  const tables = this.sql.exec(`
    SELECT name FROM sqlite_master WHERE type='table' ORDER BY name
  `).toArray()
  return Response.json({ tables: tables.map(t => t.name) })
}

Step 3: Verify schema creates

Run: npx wrangler dev Test: curl "http://localhost:8787/test/schema?did=did:plc:test" Expected: {"tables":["blocks","commits","records","seq_events"]}

Step 4: Commit

git add src/pds.js
git commit -m "feat: add SQLite schema for PDS storage"

Task 6: P-256 Signing#

Files:

  • Modify: src/pds.js

Add P-256 ECDSA signing using Web Crypto API.

Step 1: Add signing utilities

Add after TID section:

// === P-256 SIGNING ===
// Web Crypto ECDSA with P-256 curve

async function importPrivateKey(privateKeyBytes) {
  // PKCS#8 wrapper for raw P-256 private key
  const pkcs8Prefix = new Uint8Array([
    0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
    0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
    0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20
  ])

  const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32)
  pkcs8.set(pkcs8Prefix)
  pkcs8.set(privateKeyBytes, pkcs8Prefix.length)

  return crypto.subtle.importKey(
    'pkcs8',
    pkcs8,
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['sign']
  )
}

async function sign(privateKey, data) {
  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    data
  )
  return new Uint8Array(signature)
}

async function generateKeyPair() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify']
  )

  // Export private key as raw bytes
  const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
  const privateBytes = base64UrlDecode(privateJwk.d)

  // Export public key as compressed point
  const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
  const publicBytes = new Uint8Array(publicRaw)
  const compressed = compressPublicKey(publicBytes)

  return { privateKey: privateBytes, publicKey: compressed }
}

function compressPublicKey(uncompressed) {
  // uncompressed is 65 bytes: 0x04 + x(32) + y(32)
  // compressed is 33 bytes: prefix(02 or 03) + x(32)
  const x = uncompressed.slice(1, 33)
  const y = uncompressed.slice(33, 65)
  const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03
  const compressed = new Uint8Array(33)
  compressed[0] = prefix
  compressed.set(x, 1)
  return compressed
}

function base64UrlDecode(str) {
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
  const binary = atob(base64)
  const bytes = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i)
  }
  return bytes
}

function bytesToHex(bytes) {
  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2)
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
  }
  return bytes
}

Step 2: Add test endpoint

if (url.pathname === '/test/sign') {
  const kp = await generateKeyPair()
  const data = new TextEncoder().encode('test message')
  const key = await importPrivateKey(kp.privateKey)
  const sig = await sign(key, data)
  return Response.json({
    publicKey: bytesToHex(kp.publicKey),
    signature: bytesToHex(sig)
  })
}

Step 3: Verify signing works

Run: npx wrangler dev Test: curl "http://localhost:8787/test/sign?did=did:plc:test" Expected: JSON with 66-char public key hex and 128-char signature hex

Step 4: Commit

git add src/pds.js
git commit -m "feat: add P-256 ECDSA signing via Web Crypto"

Task 7: Identity Storage#

Files:

  • Modify: src/pds.js

Store DID and signing key in Durable Object storage.

Step 1: Add identity methods to class

Add to PersonalDataServer class:

async initIdentity(did, privateKeyHex) {
  await this.state.storage.put('did', did)
  await this.state.storage.put('privateKey', privateKeyHex)
}

async getDid() {
  if (!this._did) {
    this._did = await this.state.storage.get('did')
  }
  return this._did
}

async getSigningKey() {
  const hex = await this.state.storage.get('privateKey')
  if (!hex) return null
  return importPrivateKey(hexToBytes(hex))
}

Step 2: Add init endpoint

if (url.pathname === '/init') {
  const body = await request.json()
  if (!body.did || !body.privateKey) {
    return Response.json({ error: 'missing did or privateKey' }, { status: 400 })
  }
  await this.initIdentity(body.did, body.privateKey)
  return Response.json({ ok: true, did: body.did })
}

Step 3: Add status endpoint

if (url.pathname === '/status') {
  const did = await this.getDid()
  return Response.json({
    initialized: !!did,
    did: did || null
  })
}

Step 4: Verify identity storage

Run: npx wrangler dev

# Check uninitialized
curl "http://localhost:8787/status?did=did:plc:test"
# Expected: {"initialized":false,"did":null}

# Initialize
curl -X POST "http://localhost:8787/init?did=did:plc:test" \
  -H "Content-Type: application/json" \
  -d '{"did":"did:plc:test","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}'
# Expected: {"ok":true,"did":"did:plc:test"}

# Check initialized
curl "http://localhost:8787/status?did=did:plc:test"
# Expected: {"initialized":true,"did":"did:plc:test"}

Step 5: Commit

git add src/pds.js
git commit -m "feat: add identity storage and init endpoint"

Task 8: MST (Merkle Search Tree)#

Files:

  • Modify: src/pds.js

Implement simple MST that rebuilds on each write.

Step 1: Add MST utilities

Add after signing section:

// === MERKLE SEARCH TREE ===
// Simple rebuild-on-write implementation

async function sha256(data) {
  const hash = await crypto.subtle.digest('SHA-256', data)
  return new Uint8Array(hash)
}

function getKeyDepth(key) {
  // Count leading zeros in hash to determine tree depth
  const keyBytes = new TextEncoder().encode(key)
  // Sync hash for depth calculation (use first bytes of key as proxy)
  let zeros = 0
  for (const byte of keyBytes) {
    if (byte === 0) zeros += 8
    else {
      for (let i = 7; i >= 0; i--) {
        if ((byte >> i) & 1) break
        zeros++
      }
      break
    }
  }
  return Math.floor(zeros / 4)
}

class MST {
  constructor(sql) {
    this.sql = sql
  }

  async computeRoot() {
    const records = this.sql.exec(`
      SELECT collection, rkey, cid FROM records ORDER BY collection, rkey
    `).toArray()

    if (records.length === 0) {
      return null
    }

    const entries = records.map(r => ({
      key: `${r.collection}/${r.rkey}`,
      cid: r.cid
    }))

    return this.buildTree(entries, 0)
  }

  async buildTree(entries, depth) {
    if (entries.length === 0) return null

    const node = { l: null, e: [] }
    let leftEntries = []

    for (const entry of entries) {
      const keyDepth = getKeyDepth(entry.key)

      if (keyDepth > depth) {
        leftEntries.push(entry)
      } else {
        // Store accumulated left entries
        if (leftEntries.length > 0) {
          const leftCid = await this.buildTree(leftEntries, depth + 1)
          if (node.e.length === 0) {
            node.l = leftCid
          } else {
            node.e[node.e.length - 1].t = leftCid
          }
          leftEntries = []
        }
        node.e.push({ k: entry.key, v: entry.cid, t: null })
      }
    }

    // Handle remaining left entries
    if (leftEntries.length > 0) {
      const leftCid = await this.buildTree(leftEntries, depth + 1)
      if (node.e.length > 0) {
        node.e[node.e.length - 1].t = leftCid
      } else {
        node.l = leftCid
      }
    }

    // Encode and store node
    const nodeBytes = cborEncode(node)
    const nodeCid = await createCid(nodeBytes)
    const cidStr = cidToString(nodeCid)

    this.sql.exec(
      `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
      cidStr,
      nodeBytes
    )

    return cidStr
  }
}

Step 2: Add MST test endpoint

if (url.pathname === '/test/mst') {
  // Insert some test records
  this.sql.exec(`INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?)`,
    'at://did:plc:test/app.bsky.feed.post/abc', 'cid1', 'app.bsky.feed.post', 'abc', new Uint8Array([1]))
  this.sql.exec(`INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?, ?)`,
    'at://did:plc:test/app.bsky.feed.post/def', 'cid2', 'app.bsky.feed.post', 'def', new Uint8Array([2]))

  const mst = new MST(this.sql)
  const root = await mst.computeRoot()
  return Response.json({ root })
}

Step 3: Verify MST builds

Run: npx wrangler dev Test: curl "http://localhost:8787/test/mst?did=did:plc:test" Expected: JSON with root CID string

Step 4: Commit

git add src/pds.js
git commit -m "feat: add Merkle Search Tree implementation"

Task 9: createRecord Endpoint#

Files:

  • Modify: src/pds.js

Implement the core write path.

Step 1: Add createRecord method

Add to PersonalDataServer class:

async createRecord(collection, record, rkey = null) {
  const did = await this.getDid()
  if (!did) throw new Error('PDS not initialized')

  rkey = rkey || createTid()
  const uri = `at://${did}/${collection}/${rkey}`

  // Encode and hash record
  const recordBytes = cborEncode(record)
  const recordCid = await createCid(recordBytes)
  const recordCidStr = cidToString(recordCid)

  // Store block
  this.sql.exec(
    `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
    recordCidStr, recordBytes
  )

  // Store record index
  this.sql.exec(
    `INSERT OR REPLACE INTO records (uri, cid, collection, rkey, value) VALUES (?, ?, ?, ?, ?)`,
    uri, recordCidStr, collection, rkey, recordBytes
  )

  // Rebuild MST
  const mst = new MST(this.sql)
  const dataRoot = await mst.computeRoot()

  // Get previous commit
  const prevCommit = this.sql.exec(
    `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1`
  ).one()

  // Create commit
  const rev = createTid()
  const commit = {
    did,
    version: 3,
    data: dataRoot,
    rev,
    prev: prevCommit?.cid || null
  }

  // Sign commit
  const commitBytes = cborEncode(commit)
  const signingKey = await this.getSigningKey()
  const sig = await sign(signingKey, commitBytes)

  const signedCommit = { ...commit, sig }
  const signedBytes = cborEncode(signedCommit)
  const commitCid = await createCid(signedBytes)
  const commitCidStr = cidToString(commitCid)

  // Store commit block
  this.sql.exec(
    `INSERT OR REPLACE INTO blocks (cid, data) VALUES (?, ?)`,
    commitCidStr, signedBytes
  )

  // Store commit reference
  this.sql.exec(
    `INSERT INTO commits (cid, rev, prev) VALUES (?, ?, ?)`,
    commitCidStr, rev, prevCommit?.cid || null
  )

  // Sequence event
  const evt = cborEncode({
    ops: [{ action: 'create', path: `${collection}/${rkey}`, cid: recordCidStr }]
  })
  this.sql.exec(
    `INSERT INTO seq_events (did, commit_cid, evt) VALUES (?, ?, ?)`,
    did, commitCidStr, evt
  )

  return { uri, cid: recordCidStr, commit: commitCidStr }
}

Step 2: Add XRPC endpoint

if (url.pathname === '/xrpc/com.atproto.repo.createRecord') {
  if (request.method !== 'POST') {
    return Response.json({ error: 'method not allowed' }, { status: 405 })
  }

  const body = await request.json()
  if (!body.collection || !body.record) {
    return Response.json({ error: 'missing collection or record' }, { status: 400 })
  }

  try {
    const result = await this.createRecord(body.collection, body.record, body.rkey)
    return Response.json(result)
  } catch (err) {
    return Response.json({ error: err.message }, { status: 500 })
  }
}

Step 3: Verify createRecord works

Run: npx wrangler dev

# First initialize
curl -X POST "http://localhost:8787/init?did=did:plc:test123" \
  -H "Content-Type: application/json" \
  -d '{"did":"did:plc:test123","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}'

# Create a post
curl -X POST "http://localhost:8787/xrpc/com.atproto.repo.createRecord?did=did:plc:test123" \
  -H "Content-Type: application/json" \
  -d '{"collection":"app.bsky.feed.post","record":{"text":"Hello world!","createdAt":"2026-01-04T00:00:00Z"}}'

Expected: JSON with uri, cid, and commit fields

Step 4: Commit

git add src/pds.js
git commit -m "feat: add createRecord endpoint"

Task 10: getRecord Endpoint#

Files:

  • Modify: src/pds.js

Step 1: Add XRPC endpoint

if (url.pathname === '/xrpc/com.atproto.repo.getRecord') {
  const collection = url.searchParams.get('collection')
  const rkey = url.searchParams.get('rkey')

  if (!collection || !rkey) {
    return Response.json({ error: 'missing collection or rkey' }, { status: 400 })
  }

  const did = await this.getDid()
  const uri = `at://${did}/${collection}/${rkey}`

  const row = this.sql.exec(
    `SELECT cid, value FROM records WHERE uri = ?`, uri
  ).one()

  if (!row) {
    return Response.json({ error: 'record not found' }, { status: 404 })
  }

  // Decode CBOR for response (minimal decoder)
  const value = cborDecode(row.value)

  return Response.json({ uri, cid: row.cid, value })
}

Step 2: Add minimal CBOR decoder

Add after cborEncode:

function cborDecode(bytes) {
  let offset = 0

  function read() {
    const initial = bytes[offset++]
    const major = initial >> 5
    const info = initial & 0x1f

    let length = info
    if (info === 24) length = bytes[offset++]
    else if (info === 25) { length = (bytes[offset++] << 8) | bytes[offset++] }
    else if (info === 26) {
      length = (bytes[offset++] << 24) | (bytes[offset++] << 16) | (bytes[offset++] << 8) | bytes[offset++]
    }

    switch (major) {
      case 0: return length // unsigned int
      case 1: return -1 - length // negative int
      case 2: { // byte string
        const data = bytes.slice(offset, offset + length)
        offset += length
        return data
      }
      case 3: { // text string
        const data = new TextDecoder().decode(bytes.slice(offset, offset + length))
        offset += length
        return data
      }
      case 4: { // array
        const arr = []
        for (let i = 0; i < length; i++) arr.push(read())
        return arr
      }
      case 5: { // map
        const obj = {}
        for (let i = 0; i < length; i++) {
          const key = read()
          obj[key] = read()
        }
        return obj
      }
      case 7: { // special
        if (info === 20) return false
        if (info === 21) return true
        if (info === 22) return null
        return undefined
      }
    }
  }

  return read()
}

Step 3: Verify getRecord works

Run: npx wrangler dev

# Create a record first, then get it
curl "http://localhost:8787/xrpc/com.atproto.repo.getRecord?did=did:plc:test123&collection=app.bsky.feed.post&rkey=<rkey_from_create>"

Expected: JSON with uri, cid, and value (the original record)

Step 4: Commit

git add src/pds.js
git commit -m "feat: add getRecord endpoint with CBOR decoder"

Task 11: CAR File Builder#

Files:

  • Modify: src/pds.js

Build CAR (Content Addressable aRchive) files for repo export.

Step 1: Add CAR builder

Add after MST section:

// === CAR FILE BUILDER ===

function varint(n) {
  const bytes = []
  while (n >= 0x80) {
    bytes.push((n & 0x7f) | 0x80)
    n >>>= 7
  }
  bytes.push(n)
  return new Uint8Array(bytes)
}

function cidToBytes(cidStr) {
  // Decode base32lower CID string to bytes
  if (!cidStr.startsWith('b')) throw new Error('expected base32lower CID')
  return base32Decode(cidStr.slice(1))
}

function base32Decode(str) {
  const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
  let bits = 0
  let value = 0
  const output = []

  for (const char of str) {
    const idx = alphabet.indexOf(char)
    if (idx === -1) continue
    value = (value << 5) | idx
    bits += 5
    if (bits >= 8) {
      bits -= 8
      output.push((value >> bits) & 0xff)
    }
  }

  return new Uint8Array(output)
}

function buildCarFile(rootCid, blocks) {
  const parts = []

  // Header: { version: 1, roots: [rootCid] }
  const rootCidBytes = cidToBytes(rootCid)
  const header = cborEncode({ version: 1, roots: [rootCidBytes] })
  parts.push(varint(header.length))
  parts.push(header)

  // Blocks: varint(len) + cid + data
  for (const block of blocks) {
    const cidBytes = cidToBytes(block.cid)
    const blockLen = cidBytes.length + block.data.length
    parts.push(varint(blockLen))
    parts.push(cidBytes)
    parts.push(block.data)
  }

  // Concatenate all parts
  const totalLen = parts.reduce((sum, p) => sum + p.length, 0)
  const car = new Uint8Array(totalLen)
  let offset = 0
  for (const part of parts) {
    car.set(part, offset)
    offset += part.length
  }

  return car
}

Step 2: Commit

git add src/pds.js
git commit -m "feat: add CAR file builder"

Task 12: getRepo Endpoint#

Files:

  • Modify: src/pds.js

Step 1: Add XRPC endpoint

if (url.pathname === '/xrpc/com.atproto.sync.getRepo') {
  const commit = this.sql.exec(
    `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1`
  ).one()

  if (!commit) {
    return Response.json({ error: 'repo not found' }, { status: 404 })
  }

  const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray()
  const car = buildCarFile(commit.cid, blocks)

  return new Response(car, {
    headers: { 'content-type': 'application/vnd.ipld.car' }
  })
}

Step 2: Verify getRepo works

Run: npx wrangler dev

curl "http://localhost:8787/xrpc/com.atproto.sync.getRepo?did=did:plc:test123" -o repo.car
xxd repo.car | head -20

Expected: Binary CAR file starting with CBOR header

Step 3: Commit

git add src/pds.js
git commit -m "feat: add getRepo endpoint returning CAR file"

Task 13: subscribeRepos WebSocket#

Files:

  • Modify: src/pds.js

Step 1: Add WebSocket endpoint

if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') {
  const upgradeHeader = request.headers.get('Upgrade')
  if (upgradeHeader !== 'websocket') {
    return new Response('expected websocket', { status: 426 })
  }

  const { 0: client, 1: server } = new WebSocketPair()
  this.state.acceptWebSocket(server)

  // Send backlog if cursor provided
  const cursor = url.searchParams.get('cursor')
  if (cursor) {
    const events = this.sql.exec(
      `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`,
      parseInt(cursor)
    ).toArray()

    for (const evt of events) {
      server.send(this.formatEvent(evt))
    }
  }

  return new Response(null, { status: 101, webSocket: client })
}

Step 2: Add event formatting and WebSocket handlers

Add to PersonalDataServer class:

formatEvent(evt) {
  const did = this.sql.exec(`SELECT did FROM seq_events WHERE seq = ?`, evt.seq).one()?.did

  // AT Protocol frame format: header + body
  const header = cborEncode({ op: 1, t: '#commit' })
  const body = cborEncode({
    seq: evt.seq,
    rebase: false,
    tooBig: false,
    repo: did || evt.did,
    commit: cidToBytes(evt.commit_cid),
    rev: createTid(),
    since: null,
    blocks: new Uint8Array(0), // Simplified - real impl includes CAR slice
    ops: cborDecode(evt.evt).ops,
    blobs: [],
    time: new Date().toISOString()
  })

  // Concatenate header + body
  const frame = new Uint8Array(header.length + body.length)
  frame.set(header)
  frame.set(body, header.length)
  return frame
}

async webSocketMessage(ws, message) {
  // Handle ping
  if (message === 'ping') ws.send('pong')
}

async webSocketClose(ws, code, reason) {
  // Durable Object will hibernate when no connections remain
}

broadcastEvent(evt) {
  const frame = this.formatEvent(evt)
  for (const ws of this.state.getWebSockets()) {
    try {
      ws.send(frame)
    } catch (e) {
      // Client disconnected
    }
  }
}

Step 3: Update createRecord to broadcast

Add at end of createRecord method, before return:

// Broadcast to subscribers
const evtRow = this.sql.exec(
  `SELECT * FROM seq_events ORDER BY seq DESC LIMIT 1`
).one()
if (evtRow) {
  this.broadcastEvent(evtRow)
}

Step 4: Verify WebSocket works

Run: npx wrangler dev

Use websocat or similar:

websocat "ws://localhost:8787/xrpc/com.atproto.sync.subscribeRepos?did=did:plc:test123"

In another terminal, create a record — you should see bytes appear in the WebSocket connection.

Step 5: Commit

git add src/pds.js
git commit -m "feat: add subscribeRepos WebSocket endpoint"

Task 14: Clean Up Test Endpoints#

Files:

  • Modify: src/pds.js

Step 1: Remove test endpoints

Remove all /test/* endpoint handlers from the fetch method. Keep only:

  • /init
  • /status
  • /xrpc/com.atproto.repo.createRecord
  • /xrpc/com.atproto.repo.getRecord
  • /xrpc/com.atproto.sync.getRepo
  • /xrpc/com.atproto.sync.subscribeRepos

Step 2: Add proper 404 handler

return Response.json({ error: 'not found' }, { status: 404 })

Step 3: Commit

git add src/pds.js
git commit -m "chore: remove test endpoints, clean up routing"

Task 15: Deploy and Test#

Step 1: Deploy to Cloudflare

npx wrangler deploy

Step 2: Initialize with a real DID

Generate a P-256 keypair and create a did:plc (or use existing).

# Example initialization
curl -X POST "https://atproto-pds.<your-subdomain>.workers.dev/init?did=did:plc:yourActualDid" \
  -H "Content-Type: application/json" \
  -d '{"did":"did:plc:yourActualDid","privateKey":"your64CharHexPrivateKey"}'

Step 3: Create a test post

curl -X POST "https://atproto-pds.<your-subdomain>.workers.dev/xrpc/com.atproto.repo.createRecord?did=did:plc:yourActualDid" \
  -H "Content-Type: application/json" \
  -d '{"collection":"app.bsky.feed.post","record":{"$type":"app.bsky.feed.post","text":"Hello from Cloudflare PDS!","createdAt":"2026-01-04T12:00:00.000Z"}}'

Step 4: Verify repo is accessible

curl "https://atproto-pds.<your-subdomain>.workers.dev/xrpc/com.atproto.sync.getRepo?did=did:plc:yourActualDid" -o test.car

Step 5: Commit deployment config if needed

git add -A
git commit -m "chore: ready for deployment"

Summary#

Total Lines: ~400 in single file Dependencies: Zero Endpoints: 4 XRPC + 2 internal

What works:

  • Create records with proper CIDs
  • MST for repo structure
  • P-256 signed commits
  • CAR file export for relays
  • WebSocket streaming for real-time sync

What's next (future tasks):

  • Incremental MST updates
  • OAuth/JWT authentication
  • Blob storage (R2)
  • Handle resolution
  • DID:PLC registration helper