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