+1036
docs/plans/2026-01-05-federation-support.md
+1036
docs/plans/2026-01-05-federation-support.md
···
1
+
# Federation Support Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Enable the Cloudflare PDS to federate with Bluesky by adding handle resolution, DID:PLC registration, and relay notification.
6
+
7
+
**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.
8
+
9
+
**Tech Stack:** Cloudflare Workers (existing), Node.js crypto (setup script), plc.directory API, bsky.network relay
10
+
11
+
---
12
+
13
+
## Task 1: Add Handle Storage to PDS
14
+
15
+
**Files:**
16
+
- Modify: `src/pds.js`
17
+
18
+
**Step 1: Update /init endpoint to accept handle**
19
+
20
+
In `src/pds.js`, modify the `/init` endpoint to also store the handle:
21
+
22
+
```javascript
23
+
if (url.pathname === '/init') {
24
+
const body = await request.json()
25
+
if (!body.did || !body.privateKey) {
26
+
return Response.json({ error: 'missing did or privateKey' }, { status: 400 })
27
+
}
28
+
await this.initIdentity(body.did, body.privateKey, body.handle || null)
29
+
return Response.json({ ok: true, did: body.did, handle: body.handle || null })
30
+
}
31
+
```
32
+
33
+
**Step 2: Update initIdentity method**
34
+
35
+
Modify the `initIdentity` method to store handle:
36
+
37
+
```javascript
38
+
async initIdentity(did, privateKeyHex, handle = null) {
39
+
await this.state.storage.put('did', did)
40
+
await this.state.storage.put('privateKey', privateKeyHex)
41
+
if (handle) {
42
+
await this.state.storage.put('handle', handle)
43
+
}
44
+
}
45
+
```
46
+
47
+
**Step 3: Add getHandle method**
48
+
49
+
Add after `getDid()`:
50
+
51
+
```javascript
52
+
async getHandle() {
53
+
return this.state.storage.get('handle')
54
+
}
55
+
```
56
+
57
+
**Step 4: Test locally**
58
+
59
+
Run: `npx wrangler dev --port 8788`
60
+
61
+
```bash
62
+
curl -X POST "http://localhost:8788/init?did=did:plc:test" \
63
+
-H "Content-Type: application/json" \
64
+
-d '{"did":"did:plc:test","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","handle":"alice.example.com"}'
65
+
```
66
+
67
+
Expected: `{"ok":true,"did":"did:plc:test","handle":"alice.example.com"}`
68
+
69
+
**Step 5: Commit**
70
+
71
+
```bash
72
+
git add src/pds.js
73
+
git commit -m "feat: add handle storage to identity"
74
+
```
75
+
76
+
---
77
+
78
+
## Task 2: Add Handle Resolution Endpoint
79
+
80
+
**Files:**
81
+
- Modify: `src/pds.js`
82
+
83
+
**Step 1: Add /.well-known/atproto-did endpoint**
84
+
85
+
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:
86
+
87
+
```javascript
88
+
async fetch(request) {
89
+
const url = new URL(request.url)
90
+
91
+
// Handle resolution - doesn't require ?did= param
92
+
if (url.pathname === '/.well-known/atproto-did') {
93
+
const did = await this.getDid()
94
+
if (!did) {
95
+
return new Response('User not found', { status: 404 })
96
+
}
97
+
return new Response(did, {
98
+
headers: { 'Content-Type': 'text/plain' }
99
+
})
100
+
}
101
+
102
+
// ... rest of existing fetch code
103
+
```
104
+
105
+
**Step 2: Update the default export to handle /.well-known without ?did=**
106
+
107
+
The main router needs to route `/.well-known/atproto-did` requests differently. Modify the default export:
108
+
109
+
```javascript
110
+
export default {
111
+
async fetch(request, env) {
112
+
const url = new URL(request.url)
113
+
114
+
// For /.well-known/atproto-did, extract DID from subdomain
115
+
// e.g., alice.atproto-pds.chad-53c.workers.dev -> look up "alice"
116
+
if (url.pathname === '/.well-known/atproto-did') {
117
+
const host = request.headers.get('Host') || ''
118
+
// For now, use the first Durable Object (single-user PDS)
119
+
// Extract handle from subdomain if present
120
+
const did = url.searchParams.get('did') || 'default'
121
+
const id = env.PDS.idFromName(did)
122
+
const pds = env.PDS.get(id)
123
+
return pds.fetch(request)
124
+
}
125
+
126
+
const did = url.searchParams.get('did')
127
+
if (!did) {
128
+
return new Response('missing did param', { status: 400 })
129
+
}
130
+
131
+
const id = env.PDS.idFromName(did)
132
+
const pds = env.PDS.get(id)
133
+
return pds.fetch(request)
134
+
}
135
+
}
136
+
```
137
+
138
+
**Step 3: Test locally**
139
+
140
+
Run: `npx wrangler dev --port 8788`
141
+
142
+
First init:
143
+
```bash
144
+
curl -X POST "http://localhost:8788/init?did=did:plc:testhandle" \
145
+
-H "Content-Type: application/json" \
146
+
-d '{"did":"did:plc:testhandle","privateKey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","handle":"alice"}'
147
+
```
148
+
149
+
Then test resolution:
150
+
```bash
151
+
curl "http://localhost:8788/.well-known/atproto-did?did=did:plc:testhandle"
152
+
```
153
+
154
+
Expected: `did:plc:testhandle`
155
+
156
+
**Step 4: Commit**
157
+
158
+
```bash
159
+
git add src/pds.js
160
+
git commit -m "feat: add handle resolution endpoint"
161
+
```
162
+
163
+
---
164
+
165
+
## Task 3: Create Setup Script Skeleton
166
+
167
+
**Files:**
168
+
- Create: `scripts/setup.js`
169
+
- Modify: `package.json`
170
+
171
+
**Step 1: Create scripts directory and setup.js**
172
+
173
+
Create `scripts/setup.js`:
174
+
175
+
```javascript
176
+
#!/usr/bin/env node
177
+
178
+
/**
179
+
* PDS Setup Script
180
+
*
181
+
* Registers a did:plc, initializes the PDS, and notifies the relay.
182
+
* Zero dependencies - uses Node.js built-ins only.
183
+
*
184
+
* Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
185
+
*/
186
+
187
+
import { webcrypto } from 'crypto'
188
+
189
+
// === ARGUMENT PARSING ===
190
+
191
+
function parseArgs() {
192
+
const args = process.argv.slice(2)
193
+
const opts = {
194
+
handle: null,
195
+
pds: null,
196
+
plcUrl: 'https://plc.directory',
197
+
relayUrl: 'https://bsky.network'
198
+
}
199
+
200
+
for (let i = 0; i < args.length; i++) {
201
+
if (args[i] === '--handle' && args[i + 1]) {
202
+
opts.handle = args[++i]
203
+
} else if (args[i] === '--pds' && args[i + 1]) {
204
+
opts.pds = args[++i]
205
+
} else if (args[i] === '--plc-url' && args[i + 1]) {
206
+
opts.plcUrl = args[++i]
207
+
} else if (args[i] === '--relay-url' && args[i + 1]) {
208
+
opts.relayUrl = args[++i]
209
+
}
210
+
}
211
+
212
+
if (!opts.handle || !opts.pds) {
213
+
console.error('Usage: node scripts/setup.js --handle <handle> --pds <pds-url>')
214
+
console.error('')
215
+
console.error('Options:')
216
+
console.error(' --handle Handle name (e.g., "alice")')
217
+
console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")')
218
+
console.error(' --plc-url PLC directory URL (default: https://plc.directory)')
219
+
console.error(' --relay-url Relay URL (default: https://bsky.network)')
220
+
process.exit(1)
221
+
}
222
+
223
+
return opts
224
+
}
225
+
226
+
// === MAIN ===
227
+
228
+
async function main() {
229
+
const opts = parseArgs()
230
+
231
+
console.log('PDS Federation Setup')
232
+
console.log('====================')
233
+
console.log(`Handle: ${opts.handle}`)
234
+
console.log(`PDS: ${opts.pds}`)
235
+
console.log(`PLC: ${opts.plcUrl}`)
236
+
console.log(`Relay: ${opts.relayUrl}`)
237
+
console.log('')
238
+
239
+
// TODO: Implement in subsequent tasks
240
+
console.log('TODO: Generate keypair')
241
+
console.log('TODO: Register DID:PLC')
242
+
console.log('TODO: Initialize PDS')
243
+
console.log('TODO: Notify relay')
244
+
}
245
+
246
+
main().catch(err => {
247
+
console.error('Error:', err.message)
248
+
process.exit(1)
249
+
})
250
+
```
251
+
252
+
**Step 2: Add npm script**
253
+
254
+
Modify `package.json`:
255
+
256
+
```json
257
+
{
258
+
"name": "cloudflare-pds",
259
+
"version": "0.1.0",
260
+
"private": true,
261
+
"type": "module",
262
+
"scripts": {
263
+
"dev": "wrangler dev",
264
+
"deploy": "wrangler deploy",
265
+
"test": "node --test test/*.test.js",
266
+
"setup": "node scripts/setup.js"
267
+
}
268
+
}
269
+
```
270
+
271
+
**Step 3: Test the skeleton**
272
+
273
+
Run: `node scripts/setup.js --handle alice --pds https://example.com`
274
+
275
+
Expected:
276
+
```
277
+
PDS Federation Setup
278
+
====================
279
+
Handle: alice
280
+
PDS: https://example.com
281
+
...
282
+
```
283
+
284
+
**Step 4: Commit**
285
+
286
+
```bash
287
+
git add scripts/setup.js package.json
288
+
git commit -m "feat: add setup script skeleton"
289
+
```
290
+
291
+
---
292
+
293
+
## Task 4: Add P-256 Key Generation
294
+
295
+
**Files:**
296
+
- Modify: `scripts/setup.js`
297
+
298
+
**Step 1: Add key generation utilities**
299
+
300
+
Add after the argument parsing section:
301
+
302
+
```javascript
303
+
// === KEY GENERATION ===
304
+
305
+
async function generateP256Keypair() {
306
+
const keyPair = await webcrypto.subtle.generateKey(
307
+
{ name: 'ECDSA', namedCurve: 'P-256' },
308
+
true,
309
+
['sign', 'verify']
310
+
)
311
+
312
+
// Export private key as raw 32 bytes
313
+
const privateJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey)
314
+
const privateBytes = base64UrlDecode(privateJwk.d)
315
+
316
+
// Export public key as uncompressed point (65 bytes)
317
+
const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey)
318
+
const publicBytes = new Uint8Array(publicRaw)
319
+
320
+
// Compress public key to 33 bytes
321
+
const compressedPublic = compressPublicKey(publicBytes)
322
+
323
+
return {
324
+
privateKey: privateBytes,
325
+
publicKey: compressedPublic,
326
+
cryptoKey: keyPair.privateKey
327
+
}
328
+
}
329
+
330
+
function compressPublicKey(uncompressed) {
331
+
// uncompressed is 65 bytes: 0x04 + x(32) + y(32)
332
+
const x = uncompressed.slice(1, 33)
333
+
const y = uncompressed.slice(33, 65)
334
+
const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03
335
+
const compressed = new Uint8Array(33)
336
+
compressed[0] = prefix
337
+
compressed.set(x, 1)
338
+
return compressed
339
+
}
340
+
341
+
function base64UrlDecode(str) {
342
+
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
343
+
const binary = atob(base64)
344
+
const bytes = new Uint8Array(binary.length)
345
+
for (let i = 0; i < binary.length; i++) {
346
+
bytes[i] = binary.charCodeAt(i)
347
+
}
348
+
return bytes
349
+
}
350
+
351
+
function bytesToHex(bytes) {
352
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
353
+
}
354
+
```
355
+
356
+
**Step 2: Add did:key encoding for P-256**
357
+
358
+
```javascript
359
+
// === DID:KEY ENCODING ===
360
+
361
+
// Multicodec prefix for P-256 public key (0x1200)
362
+
const P256_MULTICODEC = new Uint8Array([0x80, 0x24])
363
+
364
+
function publicKeyToDidKey(compressedPublicKey) {
365
+
// did:key format: "did:key:" + multibase(base58btc) of multicodec + key
366
+
const keyWithCodec = new Uint8Array(P256_MULTICODEC.length + compressedPublicKey.length)
367
+
keyWithCodec.set(P256_MULTICODEC)
368
+
keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length)
369
+
370
+
return 'did:key:z' + base58btcEncode(keyWithCodec)
371
+
}
372
+
373
+
function base58btcEncode(bytes) {
374
+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
375
+
376
+
// Count leading zeros
377
+
let zeros = 0
378
+
for (const b of bytes) {
379
+
if (b === 0) zeros++
380
+
else break
381
+
}
382
+
383
+
// Convert to base58
384
+
const digits = [0]
385
+
for (const byte of bytes) {
386
+
let carry = byte
387
+
for (let i = 0; i < digits.length; i++) {
388
+
carry += digits[i] << 8
389
+
digits[i] = carry % 58
390
+
carry = (carry / 58) | 0
391
+
}
392
+
while (carry > 0) {
393
+
digits.push(carry % 58)
394
+
carry = (carry / 58) | 0
395
+
}
396
+
}
397
+
398
+
// Convert to string
399
+
let result = '1'.repeat(zeros)
400
+
for (let i = digits.length - 1; i >= 0; i--) {
401
+
result += ALPHABET[digits[i]]
402
+
}
403
+
404
+
return result
405
+
}
406
+
```
407
+
408
+
**Step 3: Update main() to generate and display keys**
409
+
410
+
```javascript
411
+
async function main() {
412
+
const opts = parseArgs()
413
+
414
+
console.log('PDS Federation Setup')
415
+
console.log('====================')
416
+
console.log(`Handle: ${opts.handle}`)
417
+
console.log(`PDS: ${opts.pds}`)
418
+
console.log('')
419
+
420
+
// Step 1: Generate keypair
421
+
console.log('Generating P-256 keypair...')
422
+
const keyPair = await generateP256Keypair()
423
+
const didKey = publicKeyToDidKey(keyPair.publicKey)
424
+
console.log(` did:key: ${didKey}`)
425
+
console.log(` Private key: ${bytesToHex(keyPair.privateKey)}`)
426
+
console.log('')
427
+
428
+
// TODO: Register DID:PLC
429
+
// TODO: Initialize PDS
430
+
// TODO: Notify relay
431
+
}
432
+
```
433
+
434
+
**Step 4: Test key generation**
435
+
436
+
Run: `node scripts/setup.js --handle alice --pds https://example.com`
437
+
438
+
Expected:
439
+
```
440
+
Generating P-256 keypair...
441
+
did:key: zDnae...
442
+
Private key: abcd1234...
443
+
```
444
+
445
+
**Step 5: Commit**
446
+
447
+
```bash
448
+
git add scripts/setup.js
449
+
git commit -m "feat: add P-256 key generation to setup script"
450
+
```
451
+
452
+
---
453
+
454
+
## Task 5: Add DID:PLC Operation Signing
455
+
456
+
**Files:**
457
+
- Modify: `scripts/setup.js`
458
+
459
+
**Step 1: Add CBOR encoding (minimal, for PLC operations)**
460
+
461
+
```javascript
462
+
// === CBOR ENCODING (minimal for PLC operations) ===
463
+
464
+
function cborEncode(value) {
465
+
const parts = []
466
+
467
+
function encode(val) {
468
+
if (val === null) {
469
+
parts.push(0xf6)
470
+
} else if (typeof val === 'string') {
471
+
const bytes = new TextEncoder().encode(val)
472
+
encodeHead(3, bytes.length)
473
+
parts.push(...bytes)
474
+
} else if (typeof val === 'number') {
475
+
if (Number.isInteger(val) && val >= 0) {
476
+
encodeHead(0, val)
477
+
}
478
+
} else if (val instanceof Uint8Array) {
479
+
encodeHead(2, val.length)
480
+
parts.push(...val)
481
+
} else if (Array.isArray(val)) {
482
+
encodeHead(4, val.length)
483
+
for (const item of val) encode(item)
484
+
} else if (typeof val === 'object') {
485
+
const keys = Object.keys(val).sort()
486
+
encodeHead(5, keys.length)
487
+
for (const key of keys) {
488
+
encode(key)
489
+
encode(val[key])
490
+
}
491
+
}
492
+
}
493
+
494
+
function encodeHead(majorType, length) {
495
+
const mt = majorType << 5
496
+
if (length < 24) {
497
+
parts.push(mt | length)
498
+
} else if (length < 256) {
499
+
parts.push(mt | 24, length)
500
+
} else if (length < 65536) {
501
+
parts.push(mt | 25, length >> 8, length & 0xff)
502
+
}
503
+
}
504
+
505
+
encode(value)
506
+
return new Uint8Array(parts)
507
+
}
508
+
```
509
+
510
+
**Step 2: Add SHA-256 hashing**
511
+
512
+
```javascript
513
+
// === HASHING ===
514
+
515
+
async function sha256(data) {
516
+
const hash = await webcrypto.subtle.digest('SHA-256', data)
517
+
return new Uint8Array(hash)
518
+
}
519
+
```
520
+
521
+
**Step 3: Add PLC operation signing**
522
+
523
+
```javascript
524
+
// === PLC OPERATIONS ===
525
+
526
+
async function signPlcOperation(operation, privateKey) {
527
+
// Encode operation without sig field
528
+
const { sig, ...opWithoutSig } = operation
529
+
const encoded = cborEncode(opWithoutSig)
530
+
531
+
// Sign with P-256
532
+
const signature = await webcrypto.subtle.sign(
533
+
{ name: 'ECDSA', hash: 'SHA-256' },
534
+
privateKey,
535
+
encoded
536
+
)
537
+
538
+
// Convert to low-S form and base64url encode
539
+
const sigBytes = ensureLowS(new Uint8Array(signature))
540
+
return base64UrlEncode(sigBytes)
541
+
}
542
+
543
+
function ensureLowS(sig) {
544
+
// P-256 order N
545
+
const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
546
+
const halfN = N / 2n
547
+
548
+
const r = sig.slice(0, 32)
549
+
const s = sig.slice(32, 64)
550
+
551
+
// Convert s to BigInt
552
+
let sInt = BigInt('0x' + bytesToHex(s))
553
+
554
+
// If s > N/2, replace with N - s
555
+
if (sInt > halfN) {
556
+
sInt = N - sInt
557
+
const newS = hexToBytes(sInt.toString(16).padStart(64, '0'))
558
+
const result = new Uint8Array(64)
559
+
result.set(r)
560
+
result.set(newS, 32)
561
+
return result
562
+
}
563
+
564
+
return sig
565
+
}
566
+
567
+
function hexToBytes(hex) {
568
+
const bytes = new Uint8Array(hex.length / 2)
569
+
for (let i = 0; i < hex.length; i += 2) {
570
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
571
+
}
572
+
return bytes
573
+
}
574
+
575
+
function base64UrlEncode(bytes) {
576
+
const binary = String.fromCharCode(...bytes)
577
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
578
+
}
579
+
580
+
async function createGenesisOperation(opts) {
581
+
const { didKey, handle, pdsUrl, cryptoKey } = opts
582
+
583
+
// Build the full handle
584
+
const pdsHost = new URL(pdsUrl).host
585
+
const fullHandle = `${handle}.${pdsHost}`
586
+
587
+
const operation = {
588
+
type: 'plc_operation',
589
+
rotationKeys: [didKey],
590
+
verificationMethods: {
591
+
atproto: didKey
592
+
},
593
+
alsoKnownAs: [`at://${fullHandle}`],
594
+
services: {
595
+
atproto_pds: {
596
+
type: 'AtprotoPersonalDataServer',
597
+
endpoint: pdsUrl
598
+
}
599
+
},
600
+
prev: null
601
+
}
602
+
603
+
// Sign the operation
604
+
operation.sig = await signPlcOperation(operation, cryptoKey)
605
+
606
+
return { operation, fullHandle }
607
+
}
608
+
609
+
async function deriveDidFromOperation(operation) {
610
+
const { sig, ...opWithoutSig } = operation
611
+
const encoded = cborEncode(opWithoutSig)
612
+
const hash = await sha256(encoded)
613
+
// DID is base32 of first 24 bytes of hash
614
+
return 'did:plc:' + base32Encode(hash.slice(0, 24))
615
+
}
616
+
617
+
function base32Encode(bytes) {
618
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
619
+
let result = ''
620
+
let bits = 0
621
+
let value = 0
622
+
623
+
for (const byte of bytes) {
624
+
value = (value << 8) | byte
625
+
bits += 8
626
+
while (bits >= 5) {
627
+
bits -= 5
628
+
result += alphabet[(value >> bits) & 31]
629
+
}
630
+
}
631
+
632
+
if (bits > 0) {
633
+
result += alphabet[(value << (5 - bits)) & 31]
634
+
}
635
+
636
+
return result
637
+
}
638
+
```
639
+
640
+
**Step 4: Update main() to create operation**
641
+
642
+
```javascript
643
+
async function main() {
644
+
const opts = parseArgs()
645
+
646
+
console.log('PDS Federation Setup')
647
+
console.log('====================')
648
+
console.log(`Handle: ${opts.handle}`)
649
+
console.log(`PDS: ${opts.pds}`)
650
+
console.log('')
651
+
652
+
// Step 1: Generate keypair
653
+
console.log('Generating P-256 keypair...')
654
+
const keyPair = await generateP256Keypair()
655
+
const didKey = publicKeyToDidKey(keyPair.publicKey)
656
+
console.log(` did:key: ${didKey}`)
657
+
console.log('')
658
+
659
+
// Step 2: Create genesis operation
660
+
console.log('Creating PLC genesis operation...')
661
+
const { operation, fullHandle } = await createGenesisOperation({
662
+
didKey,
663
+
handle: opts.handle,
664
+
pdsUrl: opts.pds,
665
+
cryptoKey: keyPair.cryptoKey
666
+
})
667
+
const did = await deriveDidFromOperation(operation)
668
+
console.log(` DID: ${did}`)
669
+
console.log(` Handle: ${fullHandle}`)
670
+
console.log('')
671
+
672
+
// TODO: Register with plc.directory
673
+
// TODO: Initialize PDS
674
+
// TODO: Notify relay
675
+
}
676
+
```
677
+
678
+
**Step 5: Test operation creation**
679
+
680
+
Run: `node scripts/setup.js --handle alice --pds https://example.com`
681
+
682
+
Expected:
683
+
```
684
+
Creating PLC genesis operation...
685
+
DID: did:plc:...
686
+
Handle: alice.example.com
687
+
```
688
+
689
+
**Step 6: Commit**
690
+
691
+
```bash
692
+
git add scripts/setup.js
693
+
git commit -m "feat: add PLC operation signing"
694
+
```
695
+
696
+
---
697
+
698
+
## Task 6: Add PLC Directory Registration
699
+
700
+
**Files:**
701
+
- Modify: `scripts/setup.js`
702
+
703
+
**Step 1: Add PLC registration function**
704
+
705
+
```javascript
706
+
// === PLC DIRECTORY REGISTRATION ===
707
+
708
+
async function registerWithPlc(plcUrl, did, operation) {
709
+
const url = `${plcUrl}/${encodeURIComponent(did)}`
710
+
711
+
const response = await fetch(url, {
712
+
method: 'POST',
713
+
headers: {
714
+
'Content-Type': 'application/json'
715
+
},
716
+
body: JSON.stringify(operation)
717
+
})
718
+
719
+
if (!response.ok) {
720
+
const text = await response.text()
721
+
throw new Error(`PLC registration failed: ${response.status} ${text}`)
722
+
}
723
+
724
+
return true
725
+
}
726
+
```
727
+
728
+
**Step 2: Update main() to register**
729
+
730
+
Add after operation creation:
731
+
732
+
```javascript
733
+
// Step 3: Register with PLC directory
734
+
console.log(`Registering with ${opts.plcUrl}...`)
735
+
await registerWithPlc(opts.plcUrl, did, operation)
736
+
console.log(' Registered successfully!')
737
+
console.log('')
738
+
```
739
+
740
+
**Step 3: Commit**
741
+
742
+
```bash
743
+
git add scripts/setup.js
744
+
git commit -m "feat: add PLC directory registration"
745
+
```
746
+
747
+
---
748
+
749
+
## Task 7: Add PDS Initialization
750
+
751
+
**Files:**
752
+
- Modify: `scripts/setup.js`
753
+
754
+
**Step 1: Add PDS initialization function**
755
+
756
+
```javascript
757
+
// === PDS INITIALIZATION ===
758
+
759
+
async function initializePds(pdsUrl, did, privateKeyHex, handle) {
760
+
const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`
761
+
762
+
const response = await fetch(url, {
763
+
method: 'POST',
764
+
headers: {
765
+
'Content-Type': 'application/json'
766
+
},
767
+
body: JSON.stringify({
768
+
did,
769
+
privateKey: privateKeyHex,
770
+
handle
771
+
})
772
+
})
773
+
774
+
if (!response.ok) {
775
+
const text = await response.text()
776
+
throw new Error(`PDS initialization failed: ${response.status} ${text}`)
777
+
}
778
+
779
+
return response.json()
780
+
}
781
+
```
782
+
783
+
**Step 2: Update main() to initialize PDS**
784
+
785
+
Add after PLC registration:
786
+
787
+
```javascript
788
+
// Step 4: Initialize PDS
789
+
console.log(`Initializing PDS at ${opts.pds}...`)
790
+
const privateKeyHex = bytesToHex(keyPair.privateKey)
791
+
await initializePds(opts.pds, did, privateKeyHex, fullHandle)
792
+
console.log(' PDS initialized!')
793
+
console.log('')
794
+
```
795
+
796
+
**Step 3: Commit**
797
+
798
+
```bash
799
+
git add scripts/setup.js
800
+
git commit -m "feat: add PDS initialization to setup script"
801
+
```
802
+
803
+
---
804
+
805
+
## Task 8: Add Relay Notification
806
+
807
+
**Files:**
808
+
- Modify: `scripts/setup.js`
809
+
810
+
**Step 1: Add relay notification function**
811
+
812
+
```javascript
813
+
// === RELAY NOTIFICATION ===
814
+
815
+
async function notifyRelay(relayUrl, pdsHostname) {
816
+
const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`
817
+
818
+
const response = await fetch(url, {
819
+
method: 'POST',
820
+
headers: {
821
+
'Content-Type': 'application/json'
822
+
},
823
+
body: JSON.stringify({
824
+
hostname: pdsHostname
825
+
})
826
+
})
827
+
828
+
// Relay might return 200 or 202, both are OK
829
+
if (!response.ok && response.status !== 202) {
830
+
const text = await response.text()
831
+
console.warn(` Warning: Relay notification returned ${response.status}: ${text}`)
832
+
return false
833
+
}
834
+
835
+
return true
836
+
}
837
+
```
838
+
839
+
**Step 2: Update main() to notify relay**
840
+
841
+
Add after PDS initialization:
842
+
843
+
```javascript
844
+
// Step 5: Notify relay
845
+
const pdsHostname = new URL(opts.pds).host
846
+
console.log(`Notifying relay at ${opts.relayUrl}...`)
847
+
const relayOk = await notifyRelay(opts.relayUrl, pdsHostname)
848
+
if (relayOk) {
849
+
console.log(' Relay notified!')
850
+
}
851
+
console.log('')
852
+
```
853
+
854
+
**Step 3: Commit**
855
+
856
+
```bash
857
+
git add scripts/setup.js
858
+
git commit -m "feat: add relay notification to setup script"
859
+
```
860
+
861
+
---
862
+
863
+
## Task 9: Add Credentials File Output
864
+
865
+
**Files:**
866
+
- Modify: `scripts/setup.js`
867
+
868
+
**Step 1: Add fs import and credentials saving**
869
+
870
+
At the top of the file, add:
871
+
872
+
```javascript
873
+
import { writeFileSync } from 'fs'
874
+
```
875
+
876
+
**Step 2: Add credentials saving function**
877
+
878
+
```javascript
879
+
// === CREDENTIALS OUTPUT ===
880
+
881
+
function saveCredentials(filename, credentials) {
882
+
writeFileSync(filename, JSON.stringify(credentials, null, 2))
883
+
}
884
+
```
885
+
886
+
**Step 3: Update main() with final output**
887
+
888
+
Replace the end of main() with:
889
+
890
+
```javascript
891
+
// Step 6: Save credentials
892
+
const credentials = {
893
+
handle: fullHandle,
894
+
did,
895
+
privateKeyHex: bytesToHex(keyPair.privateKey),
896
+
didKey,
897
+
pdsUrl: opts.pds,
898
+
createdAt: new Date().toISOString()
899
+
}
900
+
901
+
const credentialsFile = `./credentials-${opts.handle}.json`
902
+
saveCredentials(credentialsFile, credentials)
903
+
904
+
// Final output
905
+
console.log('Setup Complete!')
906
+
console.log('===============')
907
+
console.log(`Handle: ${fullHandle}`)
908
+
console.log(`DID: ${did}`)
909
+
console.log(`PDS: ${opts.pds}`)
910
+
console.log('')
911
+
console.log(`Credentials saved to: ${credentialsFile}`)
912
+
console.log('Keep this file safe - it contains your private key!')
913
+
}
914
+
```
915
+
916
+
**Step 4: Add .gitignore entry**
917
+
918
+
Add to `.gitignore` (create if doesn't exist):
919
+
920
+
```
921
+
credentials-*.json
922
+
```
923
+
924
+
**Step 5: Commit**
925
+
926
+
```bash
927
+
git add scripts/setup.js .gitignore
928
+
git commit -m "feat: add credentials file output"
929
+
```
930
+
931
+
---
932
+
933
+
## Task 10: Deploy and Test End-to-End
934
+
935
+
**Files:**
936
+
- None (testing only)
937
+
938
+
**Step 1: Deploy updated PDS**
939
+
940
+
```bash
941
+
source .env && npx wrangler deploy
942
+
```
943
+
944
+
**Step 2: Run full setup**
945
+
946
+
```bash
947
+
node scripts/setup.js --handle testuser --pds https://atproto-pds.chad-53c.workers.dev
948
+
```
949
+
950
+
Expected output:
951
+
```
952
+
PDS Federation Setup
953
+
====================
954
+
Handle: testuser
955
+
PDS: https://atproto-pds.chad-53c.workers.dev
956
+
957
+
Generating P-256 keypair...
958
+
did:key: zDnae...
959
+
960
+
Creating PLC genesis operation...
961
+
DID: did:plc:...
962
+
Handle: testuser.atproto-pds.chad-53c.workers.dev
963
+
964
+
Registering with https://plc.directory...
965
+
Registered successfully!
966
+
967
+
Initializing PDS at https://atproto-pds.chad-53c.workers.dev...
968
+
PDS initialized!
969
+
970
+
Notifying relay at https://bsky.network...
971
+
Relay notified!
972
+
973
+
Setup Complete!
974
+
===============
975
+
Handle: testuser.atproto-pds.chad-53c.workers.dev
976
+
DID: did:plc:...
977
+
PDS: https://atproto-pds.chad-53c.workers.dev
978
+
979
+
Credentials saved to: ./credentials-testuser.json
980
+
```
981
+
982
+
**Step 3: Verify handle resolution**
983
+
984
+
```bash
985
+
curl "https://atproto-pds.chad-53c.workers.dev/.well-known/atproto-did?did=<your-did>"
986
+
```
987
+
988
+
Expected: Returns the DID as plain text
989
+
990
+
**Step 4: Verify on plc.directory**
991
+
992
+
```bash
993
+
curl "https://plc.directory/<your-did>"
994
+
```
995
+
996
+
Expected: Returns DID document with your PDS as the service endpoint
997
+
998
+
**Step 5: Create a test post**
999
+
1000
+
```bash
1001
+
curl -X POST "https://atproto-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord?did=<your-did>" \
1002
+
-H "Content-Type: application/json" \
1003
+
-d '{"collection":"app.bsky.feed.post","record":{"text":"Hello from my Cloudflare PDS!","createdAt":"2026-01-05T12:00:00.000Z"}}'
1004
+
```
1005
+
1006
+
**Step 6: Commit final state**
1007
+
1008
+
```bash
1009
+
git add -A
1010
+
git commit -m "chore: federation support complete"
1011
+
```
1012
+
1013
+
---
1014
+
1015
+
## Summary
1016
+
1017
+
**Files created/modified:**
1018
+
- `src/pds.js` - Added handle storage and `/.well-known/atproto-did` endpoint
1019
+
- `scripts/setup.js` - Complete setup script (~300 lines, zero dependencies)
1020
+
- `package.json` - Added `setup` script
1021
+
- `.gitignore` - Added credentials file pattern
1022
+
1023
+
**What the setup script does:**
1024
+
1. Generates P-256 keypair
1025
+
2. Creates did:key from public key
1026
+
3. Builds and signs PLC genesis operation
1027
+
4. Derives DID from operation
1028
+
5. Registers with plc.directory
1029
+
6. Initializes PDS with identity
1030
+
7. Notifies relay
1031
+
8. Saves credentials to file
1032
+
1033
+
**Usage:**
1034
+
```bash
1035
+
npm run setup -- --handle yourname --pds https://your-pds.workers.dev
1036
+
```