A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

feat: add DID update script and fix bare domain handle resolution

- Add scripts/update-did.js for updating PLC DID handle and PDS endpoint
- Fix handle resolution to work with bare domain handles (not just subdomains)
- This allows workers.dev deployments where nested subdomains aren't supported

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+284 -17
scripts
src
+13 -11
scripts/setup.js
··· 35 35 } 36 36 } 37 37 38 - if (!opts.handle || !opts.pds) { 39 - console.error('Usage: node scripts/setup.js --handle <subdomain> --pds <pds-url>') 38 + if (!opts.pds) { 39 + console.error('Usage: node scripts/setup.js --pds <pds-url> [--handle <subdomain>]') 40 40 console.error('') 41 41 console.error('Options:') 42 - console.error(' --handle Subdomain handle (e.g., "alice")') 43 42 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 43 + console.error(' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted') 44 44 console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 45 45 console.error(' --relay-url Relay URL (default: https://bsky.network)') 46 46 process.exit(1) ··· 289 289 async function createGenesisOperation(opts) { 290 290 const { didKey, handle, pdsUrl, cryptoKey } = opts 291 291 292 - // Build full handle: subdomain.pds-hostname 292 + // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain 293 293 const pdsHost = new URL(pdsUrl).host 294 - const fullHandle = `${handle}.${pdsHost}` 294 + const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost 295 295 296 296 const operation = { 297 297 type: 'plc_operation', ··· 486 486 console.log(' PDS initialized!') 487 487 console.log('') 488 488 489 - // Step 4b: Register handle -> DID mapping 490 - console.log(`Registering handle mapping...`) 491 - await registerHandle(opts.pds, opts.handle, did) 492 - console.log(` Handle ${opts.handle} -> ${did}`) 493 - console.log('') 489 + // Step 4b: Register handle -> DID mapping (only for subdomain handles) 490 + if (opts.handle) { 491 + console.log(`Registering handle mapping...`) 492 + await registerHandle(opts.pds, opts.handle, did) 493 + console.log(` Handle ${opts.handle} -> ${did}`) 494 + console.log('') 495 + } 494 496 495 497 // Step 5: Notify relay 496 498 const pdsHostname = new URL(opts.pds).host ··· 511 513 createdAt: new Date().toISOString() 512 514 } 513 515 514 - const credentialsFile = `./credentials-${opts.handle}.json` 516 + const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json` 515 517 saveCredentials(credentialsFile, credentials) 516 518 517 519 // Final output
+267
scripts/update-did.js
··· 1 + #!/usr/bin/env node 2 + 3 + /** 4 + * Update DID handle and PDS endpoint 5 + * 6 + * Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url> 7 + */ 8 + 9 + import { webcrypto } from 'crypto' 10 + import { readFileSync, writeFileSync } from 'fs' 11 + 12 + // === ARGUMENT PARSING === 13 + 14 + function parseArgs() { 15 + const args = process.argv.slice(2) 16 + const opts = { 17 + credentials: null, 18 + newHandle: null, 19 + newPds: null, 20 + plcUrl: 'https://plc.directory' 21 + } 22 + 23 + for (let i = 0; i < args.length; i++) { 24 + if (args[i] === '--credentials' && args[i + 1]) { 25 + opts.credentials = args[++i] 26 + } else if (args[i] === '--new-handle' && args[i + 1]) { 27 + opts.newHandle = args[++i] 28 + } else if (args[i] === '--new-pds' && args[i + 1]) { 29 + opts.newPds = args[++i] 30 + } else if (args[i] === '--plc-url' && args[i + 1]) { 31 + opts.plcUrl = args[++i] 32 + } 33 + } 34 + 35 + if (!opts.credentials || !opts.newHandle || !opts.newPds) { 36 + console.error('Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>') 37 + process.exit(1) 38 + } 39 + 40 + return opts 41 + } 42 + 43 + // === CRYPTO HELPERS === 44 + 45 + function hexToBytes(hex) { 46 + const bytes = new Uint8Array(hex.length / 2) 47 + for (let i = 0; i < hex.length; i += 2) { 48 + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 49 + } 50 + return bytes 51 + } 52 + 53 + function bytesToHex(bytes) { 54 + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 55 + } 56 + 57 + async function importPrivateKey(privateKeyBytes) { 58 + const pkcs8Prefix = new Uint8Array([ 59 + 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 60 + 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 61 + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 62 + ]) 63 + 64 + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) 65 + pkcs8.set(pkcs8Prefix) 66 + pkcs8.set(privateKeyBytes, pkcs8Prefix.length) 67 + 68 + return webcrypto.subtle.importKey( 69 + 'pkcs8', 70 + pkcs8, 71 + { name: 'ECDSA', namedCurve: 'P-256' }, 72 + false, 73 + ['sign'] 74 + ) 75 + } 76 + 77 + // === CBOR ENCODING === 78 + 79 + function cborEncodeKey(key) { 80 + const bytes = new TextEncoder().encode(key) 81 + const parts = [] 82 + const mt = 3 << 5 83 + if (bytes.length < 24) { 84 + parts.push(mt | bytes.length) 85 + } else if (bytes.length < 256) { 86 + parts.push(mt | 24, bytes.length) 87 + } 88 + parts.push(...bytes) 89 + return new Uint8Array(parts) 90 + } 91 + 92 + function compareBytes(a, b) { 93 + const minLen = Math.min(a.length, b.length) 94 + for (let i = 0; i < minLen; i++) { 95 + if (a[i] !== b[i]) return a[i] - b[i] 96 + } 97 + return a.length - b.length 98 + } 99 + 100 + function cborEncode(value) { 101 + const parts = [] 102 + 103 + function encode(val) { 104 + if (val === null) { 105 + parts.push(0xf6) 106 + } else if (typeof val === 'string') { 107 + const bytes = new TextEncoder().encode(val) 108 + encodeHead(3, bytes.length) 109 + parts.push(...bytes) 110 + } else if (typeof val === 'number') { 111 + if (Number.isInteger(val) && val >= 0) { 112 + encodeHead(0, val) 113 + } 114 + } else if (val instanceof Uint8Array) { 115 + encodeHead(2, val.length) 116 + parts.push(...val) 117 + } else if (Array.isArray(val)) { 118 + encodeHead(4, val.length) 119 + for (const item of val) encode(item) 120 + } else if (typeof val === 'object') { 121 + const keys = Object.keys(val) 122 + const keysSorted = keys.sort((a, b) => compareBytes(cborEncodeKey(a), cborEncodeKey(b))) 123 + encodeHead(5, keysSorted.length) 124 + for (const key of keysSorted) { 125 + encode(key) 126 + encode(val[key]) 127 + } 128 + } 129 + } 130 + 131 + function encodeHead(majorType, length) { 132 + const mt = majorType << 5 133 + if (length < 24) { 134 + parts.push(mt | length) 135 + } else if (length < 256) { 136 + parts.push(mt | 24, length) 137 + } else if (length < 65536) { 138 + parts.push(mt | 25, length >> 8, length & 0xff) 139 + } 140 + } 141 + 142 + encode(value) 143 + return new Uint8Array(parts) 144 + } 145 + 146 + // === SIGNING === 147 + 148 + const P256_N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') 149 + 150 + function ensureLowS(sig) { 151 + const halfN = P256_N / 2n 152 + const r = sig.slice(0, 32) 153 + const s = sig.slice(32, 64) 154 + let sInt = BigInt('0x' + bytesToHex(s)) 155 + 156 + if (sInt > halfN) { 157 + sInt = P256_N - sInt 158 + const newS = hexToBytes(sInt.toString(16).padStart(64, '0')) 159 + const result = new Uint8Array(64) 160 + result.set(r) 161 + result.set(newS, 32) 162 + return result 163 + } 164 + return sig 165 + } 166 + 167 + function base64UrlEncode(bytes) { 168 + const binary = String.fromCharCode(...bytes) 169 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 170 + } 171 + 172 + async function signPlcOperation(operation, privateKey) { 173 + const { sig, ...opWithoutSig } = operation 174 + const encoded = cborEncode(opWithoutSig) 175 + 176 + const signature = await webcrypto.subtle.sign( 177 + { name: 'ECDSA', hash: 'SHA-256' }, 178 + privateKey, 179 + encoded 180 + ) 181 + 182 + const sigBytes = ensureLowS(new Uint8Array(signature)) 183 + return base64UrlEncode(sigBytes) 184 + } 185 + 186 + // === MAIN === 187 + 188 + async function main() { 189 + const opts = parseArgs() 190 + 191 + // Load credentials 192 + const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8')) 193 + console.log(`Updating DID: ${creds.did}`) 194 + console.log(` Old handle: ${creds.handle}`) 195 + console.log(` New handle: ${opts.newHandle}`) 196 + console.log(` New PDS: ${opts.newPds}`) 197 + console.log('') 198 + 199 + // Fetch current operation log 200 + console.log('Fetching current PLC operation log...') 201 + const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`) 202 + if (!logRes.ok) { 203 + throw new Error(`Failed to fetch PLC log: ${logRes.status}`) 204 + } 205 + const log = await logRes.json() 206 + const lastOp = log[log.length - 1] 207 + console.log(` Found ${log.length} operations`) 208 + console.log(` Last CID: ${lastOp.cid}`) 209 + console.log('') 210 + 211 + // Import private key 212 + const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex)) 213 + 214 + // Create new operation 215 + const newOp = { 216 + type: 'plc_operation', 217 + rotationKeys: lastOp.operation.rotationKeys, 218 + verificationMethods: lastOp.operation.verificationMethods, 219 + alsoKnownAs: [`at://${opts.newHandle}`], 220 + services: { 221 + atproto_pds: { 222 + type: 'AtprotoPersonalDataServer', 223 + endpoint: opts.newPds 224 + } 225 + }, 226 + prev: lastOp.cid 227 + } 228 + 229 + // Sign the operation 230 + console.log('Signing new operation...') 231 + newOp.sig = await signPlcOperation(newOp, privateKey) 232 + 233 + // Submit to PLC 234 + console.log('Submitting to PLC directory...') 235 + const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, { 236 + method: 'POST', 237 + headers: { 'Content-Type': 'application/json' }, 238 + body: JSON.stringify(newOp) 239 + }) 240 + 241 + if (!submitRes.ok) { 242 + const text = await submitRes.text() 243 + throw new Error(`PLC update failed: ${submitRes.status} ${text}`) 244 + } 245 + 246 + console.log(' Updated successfully!') 247 + console.log('') 248 + 249 + // Update credentials file 250 + creds.handle = opts.newHandle 251 + creds.pdsUrl = opts.newPds 252 + writeFileSync(opts.credentials, JSON.stringify(creds, null, 2)) 253 + console.log(`Updated credentials file: ${opts.credentials}`) 254 + 255 + // Verify 256 + console.log('') 257 + console.log('Verifying...') 258 + const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`) 259 + const didDoc = await verifyRes.json() 260 + console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`) 261 + console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`) 262 + } 263 + 264 + main().catch(err => { 265 + console.error('Error:', err.message) 266 + process.exit(1) 267 + })
+4 -6
src/pds.js
··· 1585 1585 const url = new URL(request.url) 1586 1586 const subdomain = getSubdomain(url.hostname) 1587 1587 1588 - // Handle resolution via subdomain 1588 + // Handle resolution via subdomain or bare domain 1589 1589 if (url.pathname === '/.well-known/atproto-did') { 1590 - if (!subdomain) { 1591 - // Bare domain - no user here 1592 - return new Response('No user at bare domain. Use a subdomain like alice.' + url.hostname, { status: 404 }) 1593 - } 1594 1590 // Look up handle -> DID in default DO 1591 + // Use subdomain if present, otherwise try bare hostname as handle 1592 + const handleToResolve = subdomain || url.hostname 1595 1593 const defaultId = env.PDS.idFromName('default') 1596 1594 const defaultPds = env.PDS.get(defaultId) 1597 1595 const resolveRes = await defaultPds.fetch( 1598 - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(subdomain)}`) 1596 + new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`) 1599 1597 ) 1600 1598 if (!resolveRes.ok) { 1601 1599 return new Response('Handle not found', { status: 404 })