chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/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 { generateKemKey } from '@pdsjs/core/kem';
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 <pds-url> [--handle <subdomain>]',
);
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, kemDidKey, 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,
atproto_kem: kemDidKey,
},
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,
kemKeyHex,
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,
kemKey: kemKeyHex,
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('');
// The key space syncers derive a shared secret against. It only ever
// reaches the PDS.
const kemKey = await generateKemKey();
// Step 2: Create genesis operation
console.log('Creating PLC genesis operation...');
const { operation, fullHandle } = await createGenesisOperation({
didKey,
recoveryDidKey,
kemDidKey: kemKey.didKey,
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,
bytesToHex(kemKey.privateKey),
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);
});