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});