Federation Support Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Enable the Cloudflare PDS to federate with Bluesky by adding handle resolution, DID:PLC registration, and relay notification.
Architecture: Add /.well-known/atproto-did endpoint to resolve handles to DIDs. Create a zero-dependency Node.js setup script that generates P-256 keys, registers a did:plc with plc.directory, initializes the PDS, and notifies the relay.
Tech Stack: Cloudflare Workers (existing), Node.js crypto (setup script), plc.directory API, bsky.network relay
Task 1: Add Handle Storage to PDS#
Files:
- Modify:
src/pds.js
Step 1: Update /init endpoint to accept handle
In src/pds.js, modify the /init endpoint to also store the handle:
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, body.handle || null)
return Response.json({ ok: true, did: body.did, handle: body.handle || null })
}
Step 2: Update initIdentity method
Modify the initIdentity method to store handle:
async initIdentity(did, privateKeyHex, handle = null) {
await this.state.storage.put('did', did)
await this.state.storage.put('privateKey', privateKeyHex)
if (handle) {
await this.state.storage.put('handle', handle)
}
}
Step 3: Add getHandle method
Add after getDid():
async getHandle() {
return this.state.storage.get('handle')
}
Step 4: Test locally
Run: npx wrangler dev --port 8788
curl -X POST "http://localhost:8788/init?did=did:plc:test" \
-H "Content-Type: application/json" \
-d '{"did":"did:plc:test","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","handle":"alice.example.com"}'
Expected: {"ok":true,"did":"did:plc:test","handle":"alice.example.com"}
Step 5: Commit
git add src/pds.js
git commit -m "feat: add handle storage to identity"
Task 2: Add Handle Resolution Endpoint#
Files:
- Modify:
src/pds.js
Step 1: Add /.well-known/atproto-did endpoint
Add this at the START of the fetch() method, BEFORE the if (url.pathname === '/init') check. This endpoint should not require the ?did= query parameter:
async fetch(request) {
const url = new URL(request.url)
// Handle resolution - doesn't require ?did= param
if (url.pathname === '/.well-known/atproto-did') {
const did = await this.getDid()
if (!did) {
return new Response('User not found', { status: 404 })
}
return new Response(did, {
headers: { 'Content-Type': 'text/plain' }
})
}
// ... rest of existing fetch code
Step 2: Update the default export to handle /.well-known without ?did=
The main router needs to route /.well-known/atproto-did requests differently. Modify the default export:
export default {
async fetch(request, env) {
const url = new URL(request.url)
// For /.well-known/atproto-did, extract DID from subdomain
// e.g., alice.atproto-pds.chad-53c.workers.dev -> look up "alice"
if (url.pathname === '/.well-known/atproto-did') {
const host = request.headers.get('Host') || ''
// For now, use the first Durable Object (single-user PDS)
// Extract handle from subdomain if present
const did = url.searchParams.get('did') || 'default'
const id = env.PDS.idFromName(did)
const pds = env.PDS.get(id)
return pds.fetch(request)
}
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 3: Test locally
Run: npx wrangler dev --port 8788
First init:
curl -X POST "http://localhost:8788/init?did=did:plc:testhandle" \
-H "Content-Type: application/json" \
-d '{"did":"did:plc:testhandle","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","handle":"alice"}'
Then test resolution:
curl "http://localhost:8788/.well-known/atproto-did?did=did:plc:testhandle"
Expected: did:plc:testhandle
Step 4: Commit
git add src/pds.js
git commit -m "feat: add handle resolution endpoint"
Task 3: Create Setup Script Skeleton#
Files:
- Create:
scripts/setup.js - Modify:
package.json
Step 1: Create scripts directory and setup.js
Create scripts/setup.js:
#!/usr/bin/env node
/**
* PDS Setup Script
*
* Registers a did:plc, initializes the PDS, and notifies the relay.
* Zero dependencies - uses Node.js built-ins only.
*
* Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
*/
import { webcrypto } from 'crypto'
// === ARGUMENT PARSING ===
function parseArgs() {
const args = process.argv.slice(2)
const opts = {
handle: null,
pds: null,
plcUrl: 'https://plc.directory',
relayUrl: 'https://bsky.network'
}
for (let i = 0; i < args.length; i++) {
if (args[i] === '--handle' && args[i + 1]) {
opts.handle = args[++i]
} else if (args[i] === '--pds' && args[i + 1]) {
opts.pds = args[++i]
} else if (args[i] === '--plc-url' && args[i + 1]) {
opts.plcUrl = args[++i]
} else if (args[i] === '--relay-url' && args[i + 1]) {
opts.relayUrl = args[++i]
}
}
if (!opts.handle || !opts.pds) {
console.error('Usage: node scripts/setup.js --handle <handle> --pds <pds-url>')
console.error('')
console.error('Options:')
console.error(' --handle Handle name (e.g., "alice")')
console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")')
console.error(' --plc-url PLC directory URL (default: https://plc.directory)')
console.error(' --relay-url Relay URL (default: https://bsky.network)')
process.exit(1)
}
return opts
}
// === MAIN ===
async function main() {
const opts = parseArgs()
console.log('PDS Federation Setup')
console.log('====================')
console.log(`Handle: ${opts.handle}`)
console.log(`PDS: ${opts.pds}`)
console.log(`PLC: ${opts.plcUrl}`)
console.log(`Relay: ${opts.relayUrl}`)
console.log('')
// TODO: Implement in subsequent tasks
console.log('TODO: Generate keypair')
console.log('TODO: Register DID:PLC')
console.log('TODO: Initialize PDS')
console.log('TODO: Notify relay')
}
main().catch(err => {
console.error('Error:', err.message)
process.exit(1)
})
Step 2: Add npm script
Modify package.json:
{
"name": "cloudflare-pds",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "node --test test/*.test.js",
"setup": "node scripts/setup.js"
}
}
Step 3: Test the skeleton
Run: node scripts/setup.js --handle alice --pds https://example.com
Expected:
PDS Federation Setup
====================
Handle: alice
PDS: https://example.com
...
Step 4: Commit
git add scripts/setup.js package.json
git commit -m "feat: add setup script skeleton"
Task 4: Add P-256 Key Generation#
Files:
- Modify:
scripts/setup.js
Step 1: Add key generation utilities
Add after the argument parsing section:
// === KEY GENERATION ===
async function generateP256Keypair() {
const keyPair = await webcrypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
)
// Export private key as raw 32 bytes
const privateJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey)
const privateBytes = base64UrlDecode(privateJwk.d)
// Export public key as uncompressed point (65 bytes)
const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey)
const publicBytes = new Uint8Array(publicRaw)
// Compress public key to 33 bytes
const compressedPublic = compressPublicKey(publicBytes)
return {
privateKey: privateBytes,
publicKey: compressedPublic,
cryptoKey: keyPair.privateKey
}
}
function compressPublicKey(uncompressed) {
// uncompressed is 65 bytes: 0x04 + x(32) + y(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('')
}
Step 2: Add did:key encoding for P-256
// === DID:KEY ENCODING ===
// Multicodec prefix for P-256 public key (0x1200)
const P256_MULTICODEC = new Uint8Array([0x80, 0x24])
function publicKeyToDidKey(compressedPublicKey) {
// did:key format: "did:key:" + multibase(base58btc) of multicodec + key
const keyWithCodec = new Uint8Array(P256_MULTICODEC.length + compressedPublicKey.length)
keyWithCodec.set(P256_MULTICODEC)
keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length)
return 'did:key:z' + base58btcEncode(keyWithCodec)
}
function base58btcEncode(bytes) {
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
// Count leading zeros
let zeros = 0
for (const b of bytes) {
if (b === 0) zeros++
else break
}
// Convert to base58
const digits = [0]
for (const byte of bytes) {
let carry = byte
for (let i = 0; i < digits.length; i++) {
carry += digits[i] << 8
digits[i] = carry % 58
carry = (carry / 58) | 0
}
while (carry > 0) {
digits.push(carry % 58)
carry = (carry / 58) | 0
}
}
// Convert to string
let result = '1'.repeat(zeros)
for (let i = digits.length - 1; i >= 0; i--) {
result += ALPHABET[digits[i]]
}
return result
}
Step 3: Update main() to generate and display keys
async function main() {
const opts = parseArgs()
console.log('PDS Federation Setup')
console.log('====================')
console.log(`Handle: ${opts.handle}`)
console.log(`PDS: ${opts.pds}`)
console.log('')
// Step 1: Generate keypair
console.log('Generating P-256 keypair...')
const keyPair = await generateP256Keypair()
const didKey = publicKeyToDidKey(keyPair.publicKey)
console.log(` did:key: ${didKey}`)
console.log(` Private key: ${bytesToHex(keyPair.privateKey)}`)
console.log('')
// TODO: Register DID:PLC
// TODO: Initialize PDS
// TODO: Notify relay
}
Step 4: Test key generation
Run: node scripts/setup.js --handle alice --pds https://example.com
Expected:
Generating P-256 keypair...
did:key: zDnae...
Private key: abcd1234...
Step 5: Commit
git add scripts/setup.js
git commit -m "feat: add P-256 key generation to setup script"
Task 5: Add DID:PLC Operation Signing#
Files:
- Modify:
scripts/setup.js
Step 1: Add CBOR encoding (minimal, for PLC operations)
// === CBOR ENCODING (minimal for PLC operations) ===
function cborEncode(value) {
const parts = []
function encode(val) {
if (val === null) {
parts.push(0xf6)
} else if (typeof val === 'string') {
const bytes = new TextEncoder().encode(val)
encodeHead(3, bytes.length)
parts.push(...bytes)
} else if (typeof val === 'number') {
if (Number.isInteger(val) && val >= 0) {
encodeHead(0, val)
}
} else if (val instanceof Uint8Array) {
encodeHead(2, val.length)
parts.push(...val)
} else if (Array.isArray(val)) {
encodeHead(4, val.length)
for (const item of val) encode(item)
} else if (typeof val === 'object') {
const keys = Object.keys(val).sort()
encodeHead(5, keys.length)
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)
}
}
encode(value)
return new Uint8Array(parts)
}
Step 2: Add SHA-256 hashing
// === HASHING ===
async function sha256(data) {
const hash = await webcrypto.subtle.digest('SHA-256', data)
return new Uint8Array(hash)
}
Step 3: Add PLC operation signing
// === PLC OPERATIONS ===
async function signPlcOperation(operation, privateKey) {
// Encode operation without sig field
const { sig, ...opWithoutSig } = operation
const encoded = cborEncode(opWithoutSig)
// Sign with P-256
const signature = await webcrypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
encoded
)
// Convert to low-S form and base64url encode
const sigBytes = ensureLowS(new Uint8Array(signature))
return base64UrlEncode(sigBytes)
}
function ensureLowS(sig) {
// P-256 order N
const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
const halfN = N / 2n
const r = sig.slice(0, 32)
const s = sig.slice(32, 64)
// Convert s to BigInt
let sInt = BigInt('0x' + bytesToHex(s))
// If s > N/2, replace with N - s
if (sInt > halfN) {
sInt = N - sInt
const newS = hexToBytes(sInt.toString(16).padStart(64, '0'))
const result = new Uint8Array(64)
result.set(r)
result.set(newS, 32)
return result
}
return sig
}
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
}
function base64UrlEncode(bytes) {
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function createGenesisOperation(opts) {
const { didKey, handle, pdsUrl, cryptoKey } = opts
// Build the full handle
const pdsHost = new URL(pdsUrl).host
const fullHandle = `${handle}.${pdsHost}`
const operation = {
type: 'plc_operation',
rotationKeys: [didKey],
verificationMethods: {
atproto: didKey
},
alsoKnownAs: [`at://${fullHandle}`],
services: {
atproto_pds: {
type: 'AtprotoPersonalDataServer',
endpoint: pdsUrl
}
},
prev: null
}
// Sign the operation
operation.sig = await signPlcOperation(operation, cryptoKey)
return { operation, fullHandle }
}
async function deriveDidFromOperation(operation) {
const { sig, ...opWithoutSig } = operation
const encoded = cborEncode(opWithoutSig)
const hash = await sha256(encoded)
// DID is base32 of first 24 bytes of hash
return 'did:plc:' + base32Encode(hash.slice(0, 24))
}
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 4: Update main() to create operation
async function main() {
const opts = parseArgs()
console.log('PDS Federation Setup')
console.log('====================')
console.log(`Handle: ${opts.handle}`)
console.log(`PDS: ${opts.pds}`)
console.log('')
// Step 1: Generate keypair
console.log('Generating P-256 keypair...')
const keyPair = await generateP256Keypair()
const didKey = publicKeyToDidKey(keyPair.publicKey)
console.log(` did:key: ${didKey}`)
console.log('')
// Step 2: Create genesis operation
console.log('Creating PLC genesis operation...')
const { operation, fullHandle } = await createGenesisOperation({
didKey,
handle: opts.handle,
pdsUrl: opts.pds,
cryptoKey: keyPair.cryptoKey
})
const did = await deriveDidFromOperation(operation)
console.log(` DID: ${did}`)
console.log(` Handle: ${fullHandle}`)
console.log('')
// TODO: Register with plc.directory
// TODO: Initialize PDS
// TODO: Notify relay
}
Step 5: Test operation creation
Run: node scripts/setup.js --handle alice --pds https://example.com
Expected:
Creating PLC genesis operation...
DID: did:plc:...
Handle: alice.example.com
Step 6: Commit
git add scripts/setup.js
git commit -m "feat: add PLC operation signing"
Task 6: Add PLC Directory Registration#
Files:
- Modify:
scripts/setup.js
Step 1: Add PLC registration function
// === PLC DIRECTORY REGISTRATION ===
async function registerWithPlc(plcUrl, did, operation) {
const url = `${plcUrl}/${encodeURIComponent(did)}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(operation)
})
if (!response.ok) {
const text = await response.text()
throw new Error(`PLC registration failed: ${response.status} ${text}`)
}
return true
}
Step 2: Update main() to register
Add after operation creation:
// Step 3: Register with PLC directory
console.log(`Registering with ${opts.plcUrl}...`)
await registerWithPlc(opts.plcUrl, did, operation)
console.log(' Registered successfully!')
console.log('')
Step 3: Commit
git add scripts/setup.js
git commit -m "feat: add PLC directory registration"
Task 7: Add PDS Initialization#
Files:
- Modify:
scripts/setup.js
Step 1: Add PDS initialization function
// === PDS INITIALIZATION ===
async function initializePds(pdsUrl, did, privateKeyHex, handle) {
const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
did,
privateKey: privateKeyHex,
handle
})
})
if (!response.ok) {
const text = await response.text()
throw new Error(`PDS initialization failed: ${response.status} ${text}`)
}
return response.json()
}
Step 2: Update main() to initialize PDS
Add after PLC registration:
// Step 4: Initialize PDS
console.log(`Initializing PDS at ${opts.pds}...`)
const privateKeyHex = bytesToHex(keyPair.privateKey)
await initializePds(opts.pds, did, privateKeyHex, fullHandle)
console.log(' PDS initialized!')
console.log('')
Step 3: Commit
git add scripts/setup.js
git commit -m "feat: add PDS initialization to setup script"
Task 8: Add Relay Notification#
Files:
- Modify:
scripts/setup.js
Step 1: Add relay notification function
// === RELAY NOTIFICATION ===
async function notifyRelay(relayUrl, pdsHostname) {
const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
hostname: pdsHostname
})
})
// Relay might return 200 or 202, both are OK
if (!response.ok && response.status !== 202) {
const text = await response.text()
console.warn(` Warning: Relay notification returned ${response.status}: ${text}`)
return false
}
return true
}
Step 2: Update main() to notify relay
Add after PDS initialization:
// Step 5: Notify relay
const pdsHostname = new URL(opts.pds).host
console.log(`Notifying relay at ${opts.relayUrl}...`)
const relayOk = await notifyRelay(opts.relayUrl, pdsHostname)
if (relayOk) {
console.log(' Relay notified!')
}
console.log('')
Step 3: Commit
git add scripts/setup.js
git commit -m "feat: add relay notification to setup script"
Task 9: Add Credentials File Output#
Files:
- Modify:
scripts/setup.js
Step 1: Add fs import and credentials saving
At the top of the file, add:
import { writeFileSync } from 'fs'
Step 2: Add credentials saving function
// === CREDENTIALS OUTPUT ===
function saveCredentials(filename, credentials) {
writeFileSync(filename, JSON.stringify(credentials, null, 2))
}
Step 3: Update main() with final output
Replace the end of main() with:
// Step 6: Save credentials
const credentials = {
handle: fullHandle,
did,
privateKeyHex: bytesToHex(keyPair.privateKey),
didKey,
pdsUrl: opts.pds,
createdAt: new Date().toISOString()
}
const credentialsFile = `./credentials-${opts.handle}.json`
saveCredentials(credentialsFile, credentials)
// Final output
console.log('Setup Complete!')
console.log('===============')
console.log(`Handle: ${fullHandle}`)
console.log(`DID: ${did}`)
console.log(`PDS: ${opts.pds}`)
console.log('')
console.log(`Credentials saved to: ${credentialsFile}`)
console.log('Keep this file safe - it contains your private key!')
}
Step 4: Add .gitignore entry
Add to .gitignore (create if doesn't exist):
credentials-*.json
Step 5: Commit
git add scripts/setup.js .gitignore
git commit -m "feat: add credentials file output"
Task 10: Deploy and Test End-to-End#
Files:
- None (testing only)
Step 1: Deploy updated PDS
source .env && npx wrangler deploy
Step 2: Run full setup
node scripts/setup.js --handle testuser --pds https://atproto-pds.chad-53c.workers.dev
Expected output:
PDS Federation Setup
====================
Handle: testuser
PDS: https://atproto-pds.chad-53c.workers.dev
Generating P-256 keypair...
did:key: zDnae...
Creating PLC genesis operation...
DID: did:plc:...
Handle: testuser.atproto-pds.chad-53c.workers.dev
Registering with https://plc.directory...
Registered successfully!
Initializing PDS at https://atproto-pds.chad-53c.workers.dev...
PDS initialized!
Notifying relay at https://bsky.network...
Relay notified!
Setup Complete!
===============
Handle: testuser.atproto-pds.chad-53c.workers.dev
DID: did:plc:...
PDS: https://atproto-pds.chad-53c.workers.dev
Credentials saved to: ./credentials-testuser.json
Step 3: Verify handle resolution
curl "https://atproto-pds.chad-53c.workers.dev/.well-known/atproto-did?did=<your-did>"
Expected: Returns the DID as plain text
Step 4: Verify on plc.directory
curl "https://plc.directory/<your-did>"
Expected: Returns DID document with your PDS as the service endpoint
Step 5: Create a test post
curl -X POST "https://atproto-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord?did=<your-did>" \
-H "Content-Type: application/json" \
-d '{"collection":"app.bsky.feed.post","record":{"text":"Hello from my Cloudflare PDS!","createdAt":"2026-01-05T12:00:00.000Z"}}'
Step 6: Commit final state
git add -A
git commit -m "chore: federation support complete"
Summary#
Files created/modified:
src/pds.js- Added handle storage and/.well-known/atproto-didendpointscripts/setup.js- Complete setup script (~300 lines, zero dependencies)package.json- Addedsetupscript.gitignore- Added credentials file pattern
What the setup script does:
- Generates P-256 keypair
- Creates did:key from public key
- Builds and signs PLC genesis operation
- Derives DID from operation
- Registers with plc.directory
- Initializes PDS with identity
- Notifies relay
- Saves credentials to file
Usage:
npm run setup -- --handle yourname --pds https://your-pds.workers.dev