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 *
8 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
9 */
10
11import { writeFileSync } from 'node:fs';
12import {
13 base32Encode,
14 base64UrlEncode,
15 bytesToHex,
16 cborEncodeDagCbor,
17 generateKeyPair,
18 importPrivateKey,
19 sign,
20} from '../src/pds.js';
21
22// === ARGUMENT PARSING ===
23
24function parseArgs() {
25 const args = process.argv.slice(2);
26 const opts = {
27 handle: null,
28 pds: null,
29 plcUrl: 'https://plc.directory',
30 relayUrl: 'https://bsky.network',
31 };
32
33 for (let i = 0; i < args.length; i++) {
34 if (args[i] === '--handle' && args[i + 1]) {
35 opts.handle = args[++i];
36 } else if (args[i] === '--pds' && args[i + 1]) {
37 opts.pds = args[++i];
38 } else if (args[i] === '--plc-url' && args[i + 1]) {
39 opts.plcUrl = args[++i];
40 } else if (args[i] === '--relay-url' && args[i + 1]) {
41 opts.relayUrl = args[++i];
42 }
43 }
44
45 if (!opts.pds) {
46 console.error(
47 'Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]',
48 );
49 console.error('');
50 console.error('Options:');
51 console.error(
52 ' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")',
53 );
54 console.error(
55 ' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted',
56 );
57 console.error(
58 ' --plc-url PLC directory URL (default: https://plc.directory)',
59 );
60 console.error(' --relay-url Relay URL (default: https://bsky.network)');
61 process.exit(1);
62 }
63
64 return opts;
65}
66
67
68// === DID:KEY ENCODING ===
69
70// Multicodec prefix for P-256 public key (0x1200)
71const P256_MULTICODEC = new Uint8Array([0x80, 0x24]);
72
73function publicKeyToDidKey(compressedPublicKey) {
74 // did:key format: "did:key:" + multibase(base58btc) of multicodec + key
75 const keyWithCodec = new Uint8Array(
76 P256_MULTICODEC.length + compressedPublicKey.length,
77 );
78 keyWithCodec.set(P256_MULTICODEC);
79 keyWithCodec.set(compressedPublicKey, P256_MULTICODEC.length);
80
81 return `did:key:z${base58btcEncode(keyWithCodec)}`;
82}
83
84function base58btcEncode(bytes) {
85 const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
86
87 // Count leading zeros
88 let zeros = 0;
89 for (const b of bytes) {
90 if (b === 0) zeros++;
91 else break;
92 }
93
94 // Convert to base58
95 const digits = [0];
96 for (const byte of bytes) {
97 let carry = byte;
98 for (let i = 0; i < digits.length; i++) {
99 carry += digits[i] << 8;
100 digits[i] = carry % 58;
101 carry = (carry / 58) | 0;
102 }
103 while (carry > 0) {
104 digits.push(carry % 58);
105 carry = (carry / 58) | 0;
106 }
107 }
108
109 // Convert to string
110 let result = '1'.repeat(zeros);
111 for (let i = digits.length - 1; i >= 0; i--) {
112 result += ALPHABET[digits[i]];
113 }
114
115 return result;
116}
117
118// === HASHING ===
119
120async function sha256(data) {
121 const hash = await crypto.subtle.digest('SHA-256', data);
122 return new Uint8Array(hash);
123}
124
125// === PLC OPERATIONS ===
126
127async function signPlcOperation(operation, cryptoKey) {
128 // Encode operation without sig field
129 const { sig, ...opWithoutSig } = operation;
130 const encoded = cborEncodeDagCbor(opWithoutSig);
131
132 // Sign with P-256 (sign() handles low-S normalization)
133 const signature = await sign(cryptoKey, encoded);
134 return base64UrlEncode(signature);
135}
136
137async function createGenesisOperation(opts) {
138 const { didKey, handle, pdsUrl, cryptoKey } = opts;
139
140 // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain
141 const pdsHost = new URL(pdsUrl).host;
142 const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost;
143
144 const operation = {
145 type: 'plc_operation',
146 rotationKeys: [didKey],
147 verificationMethods: {
148 atproto: didKey,
149 },
150 alsoKnownAs: [`at://${fullHandle}`],
151 services: {
152 atproto_pds: {
153 type: 'AtprotoPersonalDataServer',
154 endpoint: pdsUrl,
155 },
156 },
157 prev: null,
158 };
159
160 // Sign the operation
161 operation.sig = await signPlcOperation(operation, cryptoKey);
162
163 return { operation, fullHandle };
164}
165
166async function deriveDidFromOperation(operation) {
167 // DID is computed from the FULL operation INCLUDING the signature
168 const encoded = cborEncodeDagCbor(operation);
169 const hash = await sha256(encoded);
170 // DID is base32 of first 15 bytes of hash (= 24 base32 chars)
171 return `did:plc:${base32Encode(hash.slice(0, 15))}`;
172}
173
174// === PLC DIRECTORY REGISTRATION ===
175
176async function registerWithPlc(plcUrl, did, operation) {
177 const url = `${plcUrl}/${encodeURIComponent(did)}`;
178
179 const response = await fetch(url, {
180 method: 'POST',
181 headers: {
182 'Content-Type': 'application/json',
183 },
184 body: JSON.stringify(operation),
185 });
186
187 if (!response.ok) {
188 const text = await response.text();
189 throw new Error(`PLC registration failed: ${response.status} ${text}`);
190 }
191
192 return true;
193}
194
195// === PDS INITIALIZATION ===
196
197async function initializePds(pdsUrl, did, privateKeyHex, handle) {
198 const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`;
199
200 const response = await fetch(url, {
201 method: 'POST',
202 headers: {
203 'Content-Type': 'application/json',
204 },
205 body: JSON.stringify({
206 did,
207 privateKey: privateKeyHex,
208 handle,
209 }),
210 });
211
212 if (!response.ok) {
213 const text = await response.text();
214 throw new Error(`PDS initialization failed: ${response.status} ${text}`);
215 }
216
217 return response.json();
218}
219
220// === HANDLE REGISTRATION ===
221
222async function registerHandle(pdsUrl, handle, did) {
223 const url = `${pdsUrl}/register-handle`;
224
225 const response = await fetch(url, {
226 method: 'POST',
227 headers: {
228 'Content-Type': 'application/json',
229 },
230 body: JSON.stringify({ handle, did }),
231 });
232
233 if (!response.ok) {
234 const text = await response.text();
235 throw new Error(`Handle registration failed: ${response.status} ${text}`);
236 }
237
238 return true;
239}
240
241// === RELAY NOTIFICATION ===
242
243async function notifyRelay(relayUrl, pdsHostname) {
244 const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`;
245
246 const response = await fetch(url, {
247 method: 'POST',
248 headers: {
249 'Content-Type': 'application/json',
250 },
251 body: JSON.stringify({
252 hostname: pdsHostname,
253 }),
254 });
255
256 // Relay might return 200 or 202, both are OK
257 if (!response.ok && response.status !== 202) {
258 const text = await response.text();
259 console.warn(
260 ` Warning: Relay notification returned ${response.status}: ${text}`,
261 );
262 return false;
263 }
264
265 return true;
266}
267
268// === CREDENTIALS OUTPUT ===
269
270function saveCredentials(filename, credentials) {
271 writeFileSync(filename, JSON.stringify(credentials, null, 2));
272}
273
274// === MAIN ===
275
276async function main() {
277 const opts = parseArgs();
278
279 console.log('PDS Federation Setup');
280 console.log('====================');
281 console.log(`PDS: ${opts.pds}`);
282 console.log('');
283
284 // Step 1: Generate keypair
285 console.log('Generating P-256 keypair...');
286 const keyPair = await generateKeyPair();
287 const cryptoKey = await importPrivateKey(keyPair.privateKey);
288 const didKey = publicKeyToDidKey(keyPair.publicKey);
289 console.log(` did:key: ${didKey}`);
290 console.log('');
291
292 // Step 2: Create genesis operation
293 console.log('Creating PLC genesis operation...');
294 const { operation, fullHandle } = await createGenesisOperation({
295 didKey,
296 handle: opts.handle,
297 pdsUrl: opts.pds,
298 cryptoKey,
299 });
300 const did = await deriveDidFromOperation(operation);
301 console.log(` DID: ${did}`);
302 console.log(` Handle: ${fullHandle}`);
303 console.log('');
304
305 // Step 3: Register with PLC directory
306 console.log(`Registering with ${opts.plcUrl}...`);
307 await registerWithPlc(opts.plcUrl, did, operation);
308 console.log(' Registered successfully!');
309 console.log('');
310
311 // Step 4: Initialize PDS
312 console.log(`Initializing PDS at ${opts.pds}...`);
313 const privateKeyHex = bytesToHex(keyPair.privateKey);
314 await initializePds(opts.pds, did, privateKeyHex, fullHandle);
315 console.log(' PDS initialized!');
316 console.log('');
317
318 // Step 4b: Register handle -> DID mapping (only for subdomain handles)
319 if (opts.handle) {
320 console.log(`Registering handle mapping...`);
321 await registerHandle(opts.pds, opts.handle, did);
322 console.log(` Handle ${opts.handle} -> ${did}`);
323 console.log('');
324 }
325
326 // Step 5: Notify relay
327 const pdsHostname = new URL(opts.pds).host;
328 console.log(`Notifying relay at ${opts.relayUrl}...`);
329 const relayOk = await notifyRelay(opts.relayUrl, pdsHostname);
330 if (relayOk) {
331 console.log(' Relay notified!');
332 }
333 console.log('');
334
335 // Step 6: Save credentials
336 const credentials = {
337 handle: fullHandle,
338 did,
339 privateKeyHex: bytesToHex(keyPair.privateKey),
340 didKey,
341 pdsUrl: opts.pds,
342 createdAt: new Date().toISOString(),
343 };
344
345 const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`;
346 saveCredentials(credentialsFile, credentials);
347
348 // Final output
349 console.log('Setup Complete!');
350 console.log('===============');
351 console.log(`Handle: ${fullHandle}`);
352 console.log(`DID: ${did}`);
353 console.log(`PDS: ${opts.pds}`);
354 console.log('');
355 console.log(`Credentials saved to: ${credentialsFile}`);
356 console.log('Keep this file safe - it contains your private key!');
357}
358
359main().catch((err) => {
360 console.error('Error:', err.message);
361 process.exit(1);
362});