A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto
pds
1#!/usr/bin/env node
2
3/**
4 * PDS Setup Script
5 *
6 * Registers a did:plc, initializes the PDS, and notifies the relay.
7 * Zero dependencies - uses Node.js built-ins only.
8 *
9 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
10 */
11
12import { webcrypto } from 'crypto'
13import { writeFileSync } from 'fs'
14
15// === ARGUMENT PARSING ===
16
17function parseArgs() {
18 const args = process.argv.slice(2)
19 const opts = {
20 handle: null,
21 pds: null,
22 plcUrl: 'https://plc.directory',
23 relayUrl: 'https://bsky.network'
24 }
25
26 for (let i = 0; i < args.length; i++) {
27 if (args[i] === '--handle' && args[i + 1]) {
28 opts.handle = args[++i]
29 } else if (args[i] === '--pds' && args[i + 1]) {
30 opts.pds = args[++i]
31 } else if (args[i] === '--plc-url' && args[i + 1]) {
32 opts.plcUrl = args[++i]
33 } else if (args[i] === '--relay-url' && args[i + 1]) {
34 opts.relayUrl = args[++i]
35 }
36 }
37
38 if (!opts.handle || !opts.pds) {
39 console.error('Usage: node scripts/setup.js --handle <subdomain> --pds <pds-url>')
40 console.error('')
41 console.error('Options:')
42 console.error(' --handle Subdomain handle (e.g., "alice")')
43 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")')
44 console.error(' --plc-url PLC directory URL (default: https://plc.directory)')
45 console.error(' --relay-url Relay URL (default: https://bsky.network)')
46 process.exit(1)
47 }
48
49 return opts
50}
51
52// === KEY GENERATION ===
53
54async function generateP256Keypair() {
55 const keyPair = await webcrypto.subtle.generateKey(
56 { name: 'ECDSA', namedCurve: 'P-256' },
57 true,
58 ['sign', 'verify']
59 )
60
61 // Export private key as raw 32 bytes
62 const privateJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey)
63 const privateBytes = base64UrlDecode(privateJwk.d)
64
65 // Export public key as uncompressed point (65 bytes)
66 const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey)
67 const publicBytes = new Uint8Array(publicRaw)
68
69 // Compress public key to 33 bytes
70 const compressedPublic = compressPublicKey(publicBytes)
71
72 return {
73 privateKey: privateBytes,
74 publicKey: compressedPublic,
75 cryptoKey: keyPair.privateKey
76 }
77}
78
79function compressPublicKey(uncompressed) {
80 // uncompressed is 65 bytes: 0x04 + x(32) + y(32)
81 const x = uncompressed.slice(1, 33)
82 const y = uncompressed.slice(33, 65)
83 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03
84 const compressed = new Uint8Array(33)
85 compressed[0] = prefix
86 compressed.set(x, 1)
87 return compressed
88}
89
90function base64UrlDecode(str) {
91 const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
92 const binary = atob(base64)
93 const bytes = new Uint8Array(binary.length)
94 for (let i = 0; i < binary.length; i++) {
95 bytes[i] = binary.charCodeAt(i)
96 }
97 return bytes
98}
99
100function bytesToHex(bytes) {
101 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
102}
103
104// === DID:KEY ENCODING ===
105
106// Multicodec prefix for P-256 public key (0x1200)
107const P256_MULTICODEC = new Uint8Array([0x80, 0x24])
108
109function publicKeyToDidKey(compressedPublicKey) {
110 // did:key format: "did:key:" + multibase(base58btc) of multicodec + key
111 const keyWithCodec = new Uint8Array(P256_MULTICODEC.length + compressedPublicKey.length)
112 keyWithCodec.set(P256_MULTICODEC)
113 keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length)
114
115 return 'did:key:z' + base58btcEncode(keyWithCodec)
116}
117
118function base58btcEncode(bytes) {
119 const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
120
121 // Count leading zeros
122 let zeros = 0
123 for (const b of bytes) {
124 if (b === 0) zeros++
125 else break
126 }
127
128 // Convert to base58
129 const digits = [0]
130 for (const byte of bytes) {
131 let carry = byte
132 for (let i = 0; i < digits.length; i++) {
133 carry += digits[i] << 8
134 digits[i] = carry % 58
135 carry = (carry / 58) | 0
136 }
137 while (carry > 0) {
138 digits.push(carry % 58)
139 carry = (carry / 58) | 0
140 }
141 }
142
143 // Convert to string
144 let result = '1'.repeat(zeros)
145 for (let i = digits.length - 1; i >= 0; i--) {
146 result += ALPHABET[digits[i]]
147 }
148
149 return result
150}
151
152// === CBOR ENCODING (dag-cbor compliant for PLC operations) ===
153
154function cborEncodeKey(key) {
155 // Encode a string key to CBOR bytes (for sorting)
156 const bytes = new TextEncoder().encode(key)
157 const parts = []
158 const mt = 3 << 5 // major type 3 = text string
159 if (bytes.length < 24) {
160 parts.push(mt | bytes.length)
161 } else if (bytes.length < 256) {
162 parts.push(mt | 24, bytes.length)
163 } else if (bytes.length < 65536) {
164 parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff)
165 }
166 parts.push(...bytes)
167 return new Uint8Array(parts)
168}
169
170function compareBytes(a, b) {
171 // dag-cbor: bytewise lexicographic order of encoded keys
172 const minLen = Math.min(a.length, b.length)
173 for (let i = 0; i < minLen; i++) {
174 if (a[i] !== b[i]) return a[i] - b[i]
175 }
176 return a.length - b.length
177}
178
179function cborEncode(value) {
180 const parts = []
181
182 function encode(val) {
183 if (val === null) {
184 parts.push(0xf6)
185 } else if (typeof val === 'string') {
186 const bytes = new TextEncoder().encode(val)
187 encodeHead(3, bytes.length)
188 parts.push(...bytes)
189 } else if (typeof val === 'number') {
190 if (Number.isInteger(val) && val >= 0) {
191 encodeHead(0, val)
192 }
193 } else if (val instanceof Uint8Array) {
194 encodeHead(2, val.length)
195 parts.push(...val)
196 } else if (Array.isArray(val)) {
197 encodeHead(4, val.length)
198 for (const item of val) encode(item)
199 } else if (typeof val === 'object') {
200 // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic)
201 const keys = Object.keys(val)
202 const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b)))
203 encodeHead(5, keysSorted.length)
204 for (const key of keysSorted) {
205 encode(key)
206 encode(val[key])
207 }
208 }
209 }
210
211 function encodeHead(majorType, length) {
212 const mt = majorType << 5
213 if (length < 24) {
214 parts.push(mt | length)
215 } else if (length < 256) {
216 parts.push(mt | 24, length)
217 } else if (length < 65536) {
218 parts.push(mt | 25, length >> 8, length & 0xff)
219 }
220 }
221
222 encode(value)
223 return new Uint8Array(parts)
224}
225
226// === HASHING ===
227
228async function sha256(data) {
229 const hash = await webcrypto.subtle.digest('SHA-256', data)
230 return new Uint8Array(hash)
231}
232
233// === PLC OPERATIONS ===
234
235async function signPlcOperation(operation, privateKey) {
236 // Encode operation without sig field
237 const { sig, ...opWithoutSig } = operation
238 const encoded = cborEncode(opWithoutSig)
239
240 // Sign with P-256
241 const signature = await webcrypto.subtle.sign(
242 { name: 'ECDSA', hash: 'SHA-256' },
243 privateKey,
244 encoded
245 )
246
247 // Convert to low-S form and base64url encode
248 const sigBytes = ensureLowS(new Uint8Array(signature))
249 return base64UrlEncode(sigBytes)
250}
251
252function ensureLowS(sig) {
253 // P-256 order N
254 const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551')
255 const halfN = N / 2n
256
257 const r = sig.slice(0, 32)
258 const s = sig.slice(32, 64)
259
260 // Convert s to BigInt
261 let sInt = BigInt('0x' + bytesToHex(s))
262
263 // If s > N/2, replace with N - s
264 if (sInt > halfN) {
265 sInt = N - sInt
266 const newS = hexToBytes(sInt.toString(16).padStart(64, '0'))
267 const result = new Uint8Array(64)
268 result.set(r)
269 result.set(newS, 32)
270 return result
271 }
272
273 return sig
274}
275
276function hexToBytes(hex) {
277 const bytes = new Uint8Array(hex.length / 2)
278 for (let i = 0; i < hex.length; i += 2) {
279 bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
280 }
281 return bytes
282}
283
284function base64UrlEncode(bytes) {
285 const binary = String.fromCharCode(...bytes)
286 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
287}
288
289async function createGenesisOperation(opts) {
290 const { didKey, handle, pdsUrl, cryptoKey } = opts
291
292 // Build full handle: subdomain.pds-hostname
293 const pdsHost = new URL(pdsUrl).host
294 const fullHandle = `${handle}.${pdsHost}`
295
296 const operation = {
297 type: 'plc_operation',
298 rotationKeys: [didKey],
299 verificationMethods: {
300 atproto: didKey
301 },
302 alsoKnownAs: [`at://${fullHandle}`],
303 services: {
304 atproto_pds: {
305 type: 'AtprotoPersonalDataServer',
306 endpoint: pdsUrl
307 }
308 },
309 prev: null
310 }
311
312 // Sign the operation
313 operation.sig = await signPlcOperation(operation, cryptoKey)
314
315 return { operation, fullHandle }
316}
317
318async function deriveDidFromOperation(operation) {
319 // DID is computed from the FULL operation INCLUDING the signature
320 const encoded = cborEncode(operation)
321 const hash = await sha256(encoded)
322 // DID is base32 of first 15 bytes of hash (= 24 base32 chars)
323 return 'did:plc:' + base32Encode(hash.slice(0, 15))
324}
325
326function base32Encode(bytes) {
327 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'
328 let result = ''
329 let bits = 0
330 let value = 0
331
332 for (const byte of bytes) {
333 value = (value << 8) | byte
334 bits += 8
335 while (bits >= 5) {
336 bits -= 5
337 result += alphabet[(value >> bits) & 31]
338 }
339 }
340
341 if (bits > 0) {
342 result += alphabet[(value << (5 - bits)) & 31]
343 }
344
345 return result
346}
347
348// === PLC DIRECTORY REGISTRATION ===
349
350async function registerWithPlc(plcUrl, did, operation) {
351 const url = `${plcUrl}/${encodeURIComponent(did)}`
352
353 const response = await fetch(url, {
354 method: 'POST',
355 headers: {
356 'Content-Type': 'application/json'
357 },
358 body: JSON.stringify(operation)
359 })
360
361 if (!response.ok) {
362 const text = await response.text()
363 throw new Error(`PLC registration failed: ${response.status} ${text}`)
364 }
365
366 return true
367}
368
369// === PDS INITIALIZATION ===
370
371async function initializePds(pdsUrl, did, privateKeyHex, handle) {
372 const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`
373
374 const response = await fetch(url, {
375 method: 'POST',
376 headers: {
377 'Content-Type': 'application/json'
378 },
379 body: JSON.stringify({
380 did,
381 privateKey: privateKeyHex,
382 handle
383 })
384 })
385
386 if (!response.ok) {
387 const text = await response.text()
388 throw new Error(`PDS initialization failed: ${response.status} ${text}`)
389 }
390
391 return response.json()
392}
393
394// === HANDLE REGISTRATION ===
395
396async function registerHandle(pdsUrl, handle, did) {
397 const url = `${pdsUrl}/register-handle`
398
399 const response = await fetch(url, {
400 method: 'POST',
401 headers: {
402 'Content-Type': 'application/json'
403 },
404 body: JSON.stringify({ handle, did })
405 })
406
407 if (!response.ok) {
408 const text = await response.text()
409 throw new Error(`Handle registration failed: ${response.status} ${text}`)
410 }
411
412 return true
413}
414
415// === RELAY NOTIFICATION ===
416
417async function notifyRelay(relayUrl, pdsHostname) {
418 const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`
419
420 const response = await fetch(url, {
421 method: 'POST',
422 headers: {
423 'Content-Type': 'application/json'
424 },
425 body: JSON.stringify({
426 hostname: pdsHostname
427 })
428 })
429
430 // Relay might return 200 or 202, both are OK
431 if (!response.ok && response.status !== 202) {
432 const text = await response.text()
433 console.warn(` Warning: Relay notification returned ${response.status}: ${text}`)
434 return false
435 }
436
437 return true
438}
439
440// === CREDENTIALS OUTPUT ===
441
442function saveCredentials(filename, credentials) {
443 writeFileSync(filename, JSON.stringify(credentials, null, 2))
444}
445
446// === MAIN ===
447
448async function main() {
449 const opts = parseArgs()
450
451 console.log('PDS Federation Setup')
452 console.log('====================')
453 console.log(`PDS: ${opts.pds}`)
454 console.log('')
455
456 // Step 1: Generate keypair
457 console.log('Generating P-256 keypair...')
458 const keyPair = await generateP256Keypair()
459 const didKey = publicKeyToDidKey(keyPair.publicKey)
460 console.log(` did:key: ${didKey}`)
461 console.log('')
462
463 // Step 2: Create genesis operation
464 console.log('Creating PLC genesis operation...')
465 const { operation, fullHandle } = await createGenesisOperation({
466 didKey,
467 handle: opts.handle,
468 pdsUrl: opts.pds,
469 cryptoKey: keyPair.cryptoKey
470 })
471 const did = await deriveDidFromOperation(operation)
472 console.log(` DID: ${did}`)
473 console.log(` Handle: ${fullHandle}`)
474 console.log('')
475
476 // Step 3: Register with PLC directory
477 console.log(`Registering with ${opts.plcUrl}...`)
478 await registerWithPlc(opts.plcUrl, did, operation)
479 console.log(' Registered successfully!')
480 console.log('')
481
482 // Step 4: Initialize PDS
483 console.log(`Initializing PDS at ${opts.pds}...`)
484 const privateKeyHex = bytesToHex(keyPair.privateKey)
485 await initializePds(opts.pds, did, privateKeyHex, fullHandle)
486 console.log(' PDS initialized!')
487 console.log('')
488
489 // Step 4b: Register handle -> DID mapping
490 console.log(`Registering handle mapping...`)
491 await registerHandle(opts.pds, opts.handle, did)
492 console.log(` Handle ${opts.handle} -> ${did}`)
493 console.log('')
494
495 // Step 5: Notify relay
496 const pdsHostname = new URL(opts.pds).host
497 console.log(`Notifying relay at ${opts.relayUrl}...`)
498 const relayOk = await notifyRelay(opts.relayUrl, pdsHostname)
499 if (relayOk) {
500 console.log(' Relay notified!')
501 }
502 console.log('')
503
504 // Step 6: Save credentials
505 const credentials = {
506 handle: fullHandle,
507 did,
508 privateKeyHex: bytesToHex(keyPair.privateKey),
509 didKey,
510 pdsUrl: opts.pds,
511 createdAt: new Date().toISOString()
512 }
513
514 const credentialsFile = `./credentials-${opts.handle}.json`
515 saveCredentials(credentialsFile, credentials)
516
517 // Final output
518 console.log('Setup Complete!')
519 console.log('===============')
520 console.log(`Handle: ${fullHandle}`)
521 console.log(`DID: ${did}`)
522 console.log(`PDS: ${opts.pds}`)
523 console.log('')
524 console.log(`Credentials saved to: ${credentialsFile}`)
525 console.log('Keep this file safe - it contains your private key!')
526}
527
528main().catch(err => {
529 console.error('Error:', err.message)
530 process.exit(1)
531})