#!/usr/bin/env node /** * PDS Setup Script * * Registers a did:plc, initializes the PDS, and notifies the relay. * * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev */ import { writeFileSync } from 'node:fs'; import { base32Encode, bytesToHex } from '@pdsjs/core/crypto'; import { publicKeyToDidKey, signPlcOperationObject } from '@pdsjs/core/plc'; import { cborEncodeDagCbor } from '@pdsjs/core/repo'; import { k256Signer } from '@pdsjs/node'; // === CRYPTO HELPERS === /** * An account signing key on either curve, with the signer the PLC operation * needs. secp256k1 is the default: the reference PDS gives every account one, * and some third-party verifiers (Go feed generators, labelers) accept no * other curve for service auth. WebCrypto cannot sign it, so the scalar and * the signer come from the adapter's injected implementation. * @param {'secp256k1'|'p256'} curve */ async function generateAccountKey(curve) { if (curve === 'secp256k1') { const privateKey = k256Signer.generatePrivateKey(); const publicKey = k256Signer.getPublicKey(privateKey, true); return { privateKey, publicKey, signingKey: { curve, sign: (data) => k256Signer.sign(privateKey, data), publicKey: async () => publicKey, }, }; } const { generateKeyPair, importPrivateKey } = await import( '@pdsjs/core/crypto' ); const keyPair = await generateKeyPair(); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, signingKey: await importPrivateKey(keyPair.privateKey), }; } // === ARGUMENT PARSING === function parseArgs() { const args = process.argv.slice(2); const opts = { handle: null, pds: null, plcUrl: 'https://plc.directory', relayUrl: 'https://bsky.network', curve: 'secp256k1', }; for (let i = 0; i < args.length; i++) { if (args[i] === '--handle' && args[i + 1]) { opts.handle = args[++i]; } else if (args[i] === '--pds' && args[i + 1]) { opts.pds = args[++i]; } else if (args[i] === '--plc-url' && args[i + 1]) { opts.plcUrl = args[++i]; } else if (args[i] === '--relay-url' && args[i + 1]) { opts.relayUrl = args[++i]; } else if (args[i] === '--curve' && args[i + 1]) { opts.curve = args[++i]; } } if (!opts.pds) { console.error( 'Usage: node scripts/setup.js --pds [--handle ]', ); console.error(''); console.error('Options:'); console.error( ' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")', ); console.error( ' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted', ); console.error( ' --plc-url PLC directory URL (default: https://plc.directory)', ); console.error(' --relay-url Relay URL (default: https://bsky.network)'); console.error( ' --curve Signing key curve, secp256k1 or p256 (default: secp256k1)', ); process.exit(1); } return opts; } // === DID:KEY ENCODING === // Multicodec prefix for P-256 public key (0x1200) // === HASHING === async function sha256(data) { const hash = await crypto.subtle.digest('SHA-256', data); return new Uint8Array(hash); } // === PLC OPERATIONS === async function createGenesisOperation(opts) { const { didKey, recoveryDidKey, handle, pdsUrl, cryptoKey } = opts; // Build full handle: subdomain.pds-hostname, or just pds-hostname if no // subdomain. hostname, not host — a handle must never carry a port. const pdsHost = new URL(pdsUrl).hostname; const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost; const operation = { type: 'plc_operation', // Highest priority first. The recovery key can rewrite what the signing // key authorized; the reverse is refused. rotationKeys: recoveryDidKey ? [recoveryDidKey, didKey] : [didKey], verificationMethods: { atproto: didKey, }, alsoKnownAs: [`at://${fullHandle}`], services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl, }, }, prev: null, }; // Sign the operation const signed = await signPlcOperationObject(operation, cryptoKey); return { operation: signed, fullHandle }; } async function deriveDidFromOperation(operation) { // DID is computed from the FULL operation INCLUDING the signature const encoded = cborEncodeDagCbor(operation); const hash = await sha256(encoded); // DID is base32 of first 15 bytes of hash (= 24 base32 chars) return `did:plc:${base32Encode(hash.slice(0, 15))}`; } // === PLC DIRECTORY REGISTRATION === async function registerWithPlc(plcUrl, did, operation) { const url = `${plcUrl}/${encodeURIComponent(did)}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(operation), }); if (!response.ok) { const text = await response.text(); throw new Error(`PLC registration failed: ${response.status} ${text}`); } return true; } // === PDS INITIALIZATION === async function initializePds(pdsUrl, did, privateKeyHex, handle, curve) { const url = `${pdsUrl}/init?did=${encodeURIComponent(did)}`; // A server that starts with a configured password requires it on /init. const password = process.env.PDS_PASSWORD; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ did, privateKey: privateKeyHex, handle, curve, ...(password ? { password } : {}), }), }); if (!response.ok) { const text = await response.text(); throw new Error(`PDS initialization failed: ${response.status} ${text}`); } return response.json(); } // === RELAY NOTIFICATION === async function notifyRelay(relayUrl, pdsHostname) { const url = `${relayUrl}/xrpc/com.atproto.sync.requestCrawl`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ hostname: pdsHostname, }), }); // Relay might return 200 or 202, both are OK if (!response.ok && response.status !== 202) { const text = await response.text(); console.warn( ` Warning: Relay notification returned ${response.status}: ${text}`, ); return false; } return true; } // === CREDENTIALS OUTPUT === function saveCredentials(filename, credentials) { // Owner-readable only: these files hold private keys. writeFileSync(filename, JSON.stringify(credentials, null, 2), { mode: 0o600, }); } // === MAIN === async function main() { const opts = parseArgs(); console.log('PDS Federation Setup'); console.log('===================='); console.log(`PDS: ${opts.pds}`); console.log(''); // Step 1: Generate keypair if (opts.curve !== 'secp256k1' && opts.curve !== 'p256') { console.error('--curve must be secp256k1 or p256'); process.exit(1); } console.log(`Generating ${opts.curve} keypair...`); const keyPair = await generateAccountKey(opts.curve); const didKey = publicKeyToDidKey(keyPair.publicKey, opts.curve); console.log(` signing did:key: ${didKey}`); // A second rotation key, listed first so it outranks the signing key. PLC // lets a higher-priority rotation key rewrite operations a lower one // signed, so this is what recovers the DID if the signing key is lost. // It never reaches the PDS: it signs nothing but rotations. const recoveryKey = await generateAccountKey(opts.curve); const recoveryDidKey = publicKeyToDidKey(recoveryKey.publicKey, opts.curve); console.log(` recovery did:key: ${recoveryDidKey}`); console.log(''); // Step 2: Create genesis operation console.log('Creating PLC genesis operation...'); const { operation, fullHandle } = await createGenesisOperation({ didKey, recoveryDidKey, handle: opts.handle, pdsUrl: opts.pds, cryptoKey: keyPair.signingKey, }); const did = await deriveDidFromOperation(operation); console.log(` DID: ${did}`); console.log(` Handle: ${fullHandle}`); console.log(''); // Step 3: Register with PLC directory console.log(`Registering with ${opts.plcUrl}...`); await registerWithPlc(opts.plcUrl, did, operation); console.log(' Registered successfully!'); console.log(''); // Step 4: Initialize PDS console.log(`Initializing PDS at ${opts.pds}...`); const privateKeyHex = bytesToHex(keyPair.privateKey); await initializePds(opts.pds, did, privateKeyHex, fullHandle, opts.curve); console.log(' PDS initialized!'); console.log(''); // Step 5: Notify relay const pdsHostname = new URL(opts.pds).hostname; console.log(`Notifying relay at ${opts.relayUrl}...`); const relayOk = await notifyRelay(opts.relayUrl, pdsHostname); if (relayOk) { console.log(' Relay notified!'); } console.log(''); // Step 6: Save credentials const credentials = { handle: fullHandle, did, curve: opts.curve, privateKeyHex: bytesToHex(keyPair.privateKey), didKey, recoveryDidKey, pdsUrl: opts.pds, createdAt: new Date().toISOString(), }; // The recovery key goes in its own file, so a copy of the credentials does // not carry the thing that recovers them. const recoveryFile = `./recovery-${fullHandle}.json`; saveCredentials(recoveryFile, { handle: fullHandle, did, curve: opts.curve, privateKeyHex: bytesToHex(recoveryKey.privateKey), didKey: recoveryDidKey, role: 'rotation key only, never given to the PDS', note: 'Recovers the DID if the signing key is lost. Keep it somewhere the signing key is not.', createdAt: new Date().toISOString(), }); const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json`; saveCredentials(credentialsFile, credentials); // Final output console.log('Setup Complete!'); console.log('==============='); console.log(`Handle: ${fullHandle}`); console.log(`DID: ${did}`); console.log(`PDS: ${opts.pds}`); console.log(''); console.log(`Credentials saved to: ${credentialsFile}`); console.log(`Recovery key saved to: ${recoveryFile}`); console.log('Keep this file safe - it contains your private key!'); } main().catch((err) => { console.error('Error:', err.message); process.exit(1); });