this repo has no description

feat: add subdomain-based handle routing

- Add handle -> DID mapping storage in default DO
- Route /.well-known/atproto-did based on subdomain
- Update setup script to register handle mappings
- Bare domain returns 404 for handle resolution (use subdomain)

Works with custom domains that have wildcard SSL certs.
Note: workers.dev doesn't support nested subdomains (SSL limitation).

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

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

Changed files
+123 -23
scripts
src
+46 -15
scripts/setup.js
··· 17 17 function parseArgs() { 18 18 const args = process.argv.slice(2) 19 19 const opts = { 20 + handle: null, 20 21 pds: null, 21 22 plcUrl: 'https://plc.directory', 22 23 relayUrl: 'https://bsky.network' 23 24 } 24 25 25 26 for (let i = 0; i < args.length; i++) { 26 - if (args[i] === '--pds' && args[i + 1]) { 27 + if (args[i] === '--handle' && args[i + 1]) { 28 + opts.handle = args[++i] 29 + } else if (args[i] === '--pds' && args[i + 1]) { 27 30 opts.pds = args[++i] 28 31 } else if (args[i] === '--plc-url' && args[i + 1]) { 29 32 opts.plcUrl = args[++i] ··· 32 35 } 33 36 } 34 37 35 - if (!opts.pds) { 36 - console.error('Usage: node scripts/setup.js --pds <pds-url>') 38 + if (!opts.handle || !opts.pds) { 39 + console.error('Usage: node scripts/setup.js --handle <subdomain> --pds <pds-url>') 37 40 console.error('') 38 41 console.error('Options:') 42 + console.error(' --handle Subdomain handle (e.g., "alice")') 39 43 console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') 40 44 console.error(' --plc-url PLC directory URL (default: https://plc.directory)') 41 45 console.error(' --relay-url Relay URL (default: https://bsky.network)') 42 46 process.exit(1) 43 47 } 44 - 45 - // Handle is just the PDS hostname 46 - opts.handle = new URL(opts.pds).host 47 48 48 49 return opts 49 50 } ··· 288 289 async function createGenesisOperation(opts) { 289 290 const { didKey, handle, pdsUrl, cryptoKey } = opts 290 291 291 - // Handle is already the full hostname 292 + // Build full handle: subdomain.pds-hostname 293 + const pdsHost = new URL(pdsUrl).host 294 + const fullHandle = `${handle}.${pdsHost}` 295 + 292 296 const operation = { 293 297 type: 'plc_operation', 294 298 rotationKeys: [didKey], 295 299 verificationMethods: { 296 300 atproto: didKey 297 301 }, 298 - alsoKnownAs: [`at://${handle}`], 302 + alsoKnownAs: [`at://${fullHandle}`], 299 303 services: { 300 304 atproto_pds: { 301 305 type: 'AtprotoPersonalDataServer', ··· 308 312 // Sign the operation 309 313 operation.sig = await signPlcOperation(operation, cryptoKey) 310 314 311 - return { operation, handle } 315 + return { operation, fullHandle } 312 316 } 313 317 314 318 async function deriveDidFromOperation(operation) { ··· 387 391 return response.json() 388 392 } 389 393 394 + // === HANDLE REGISTRATION === 395 + 396 + async function registerHandle(pdsUrl, handle, did) { 397 + const url = `${pdsUrl}/register-handle` 398 + 399 + const response = await fetch(url, { 400 + method: 'POST', 401 + headers: { 402 + 'Content-Type': 'application/json' 403 + }, 404 + body: JSON.stringify({ handle, did }) 405 + }) 406 + 407 + if (!response.ok) { 408 + const text = await response.text() 409 + throw new Error(`Handle registration failed: ${response.status} ${text}`) 410 + } 411 + 412 + return true 413 + } 414 + 390 415 // === RELAY NOTIFICATION === 391 416 392 417 async function notifyRelay(relayUrl, pdsHostname) { ··· 437 462 438 463 // Step 2: Create genesis operation 439 464 console.log('Creating PLC genesis operation...') 440 - const { operation, handle } = await createGenesisOperation({ 465 + const { operation, fullHandle } = await createGenesisOperation({ 441 466 didKey, 442 467 handle: opts.handle, 443 468 pdsUrl: opts.pds, ··· 445 470 }) 446 471 const did = await deriveDidFromOperation(operation) 447 472 console.log(` DID: ${did}`) 448 - console.log(` Handle: ${handle}`) 473 + console.log(` Handle: ${fullHandle}`) 449 474 console.log('') 450 475 451 476 // Step 3: Register with PLC directory ··· 457 482 // Step 4: Initialize PDS 458 483 console.log(`Initializing PDS at ${opts.pds}...`) 459 484 const privateKeyHex = bytesToHex(keyPair.privateKey) 460 - await initializePds(opts.pds, did, privateKeyHex, handle) 485 + await initializePds(opts.pds, did, privateKeyHex, fullHandle) 461 486 console.log(' PDS initialized!') 462 487 console.log('') 463 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('') 494 + 464 495 // Step 5: Notify relay 465 496 const pdsHostname = new URL(opts.pds).host 466 497 console.log(`Notifying relay at ${opts.relayUrl}...`) ··· 472 503 473 504 // Step 6: Save credentials 474 505 const credentials = { 475 - handle, 506 + handle: fullHandle, 476 507 did, 477 508 privateKeyHex: bytesToHex(keyPair.privateKey), 478 509 didKey, ··· 480 511 createdAt: new Date().toISOString() 481 512 } 482 513 483 - const credentialsFile = `./credentials.json` 514 + const credentialsFile = `./credentials-${opts.handle}.json` 484 515 saveCredentials(credentialsFile, credentials) 485 516 486 517 // Final output 487 518 console.log('Setup Complete!') 488 519 console.log('===============') 489 - console.log(`Handle: ${handle}`) 520 + console.log(`Handle: ${fullHandle}`) 490 521 console.log(`DID: ${did}`) 491 522 console.log(`PDS: ${opts.pds}`) 492 523 console.log('')
+77 -8
src/pds.js
··· 878 878 '/get-registered-dids': { 879 879 handler: (pds, req, url) => pds.handleGetRegisteredDids() 880 880 }, 881 + '/register-handle': { 882 + method: 'POST', 883 + handler: (pds, req, url) => pds.handleRegisterHandle(req) 884 + }, 885 + '/resolve-handle': { 886 + handler: (pds, req, url) => pds.handleResolveHandle(url) 887 + }, 881 888 '/repo-info': { 882 889 handler: (pds, req, url) => pds.handleRepoInfo() 883 890 }, ··· 1255 1262 return Response.json({ dids: registeredDids }) 1256 1263 } 1257 1264 1265 + async handleRegisterHandle(request) { 1266 + const body = await request.json() 1267 + const { handle, did } = body 1268 + if (!handle || !did) { 1269 + return Response.json({ error: 'missing handle or did' }, { status: 400 }) 1270 + } 1271 + const handleMap = await this.state.storage.get('handleMap') || {} 1272 + handleMap[handle] = did 1273 + await this.state.storage.put('handleMap', handleMap) 1274 + return Response.json({ ok: true }) 1275 + } 1276 + 1277 + async handleResolveHandle(url) { 1278 + const handle = url.searchParams.get('handle') 1279 + if (!handle) { 1280 + return Response.json({ error: 'missing handle' }, { status: 400 }) 1281 + } 1282 + const handleMap = await this.state.storage.get('handleMap') || {} 1283 + const did = handleMap[handle] 1284 + if (!did) { 1285 + return Response.json({ error: 'handle not found' }, { status: 404 }) 1286 + } 1287 + return Response.json({ did }) 1288 + } 1289 + 1258 1290 async handleRepoInfo() { 1259 1291 const head = await this.state.storage.get('head') 1260 1292 const rev = await this.state.storage.get('rev') ··· 1536 1568 } 1537 1569 } 1538 1570 1571 + // Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") 1572 + function getSubdomain(hostname) { 1573 + const parts = hostname.split('.') 1574 + // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev 1575 + // If more than 4 parts, first part(s) are user subdomain 1576 + if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { 1577 + return parts.slice(0, -4).join('.') 1578 + } 1579 + // Custom domains: check if there's a subdomain before the base 1580 + // For now, assume no subdomain on custom domains 1581 + return null 1582 + } 1583 + 1539 1584 async function handleRequest(request, env) { 1540 1585 const url = new URL(request.url) 1586 + const subdomain = getSubdomain(url.hostname) 1541 1587 1542 - // Endpoints that don't require ?did= param (for relay/federation) 1543 - if (url.pathname === '/.well-known/atproto-did' || 1544 - url.pathname === '/xrpc/com.atproto.server.describeServer') { 1545 - const did = url.searchParams.get('did') || 'default' 1546 - const id = env.PDS.idFromName(did) 1547 - const pds = env.PDS.get(id) 1548 - // Pass hostname for describeServer 1588 + // Handle resolution via subdomain 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 + // Look up handle -> DID in default DO 1595 + const defaultId = env.PDS.idFromName('default') 1596 + const defaultPds = env.PDS.get(defaultId) 1597 + const resolveRes = await defaultPds.fetch( 1598 + new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(subdomain)}`) 1599 + ) 1600 + if (!resolveRes.ok) { 1601 + return new Response('Handle not found', { status: 404 }) 1602 + } 1603 + const { did } = await resolveRes.json() 1604 + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 1605 + } 1606 + 1607 + // describeServer - works on bare domain 1608 + if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 1609 + const defaultId = env.PDS.idFromName('default') 1610 + const defaultPds = env.PDS.get(defaultId) 1549 1611 const newReq = new Request(request.url, { 1550 1612 method: request.method, 1551 1613 headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } 1552 1614 }) 1553 - return pds.fetch(newReq) 1615 + return defaultPds.fetch(newReq) 1616 + } 1617 + 1618 + // Handle registration routes - go to default DO 1619 + if (url.pathname === '/register-handle' || url.pathname === '/resolve-handle') { 1620 + const defaultId = env.PDS.idFromName('default') 1621 + const defaultPds = env.PDS.get(defaultId) 1622 + return defaultPds.fetch(request) 1554 1623 } 1555 1624 1556 1625 // subscribeRepos WebSocket - route to default instance for firehose