From d38f09919fe2d3ad000a57a74d5e4cadebf52e5e Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 5 Jan 2026 14:31:07 -0800 Subject: [PATCH] feat: add subdomain-based handle routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/setup.js | 61 +++++++++++++++++++++++++--------- src/pds.js | 85 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index edcd981..e335f04 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -17,13 +17,16 @@ import { writeFileSync } from 'fs' function parseArgs() { const args = process.argv.slice(2) const opts = { + handle: null, pds: null, plcUrl: 'https://plc.directory', relayUrl: 'https://bsky.network' } for (let i = 0; i < args.length; i++) { - if (args[i] === '--pds' && args[i + 1]) { + 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] @@ -32,19 +35,17 @@ function parseArgs() { } } - if (!opts.pds) { - console.error('Usage: node scripts/setup.js --pds ') + if (!opts.handle || !opts.pds) { + console.error('Usage: node scripts/setup.js --handle --pds ') console.error('') console.error('Options:') + console.error(' --handle Subdomain handle (e.g., "alice")') console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') console.error(' --plc-url PLC directory URL (default: https://plc.directory)') console.error(' --relay-url Relay URL (default: https://bsky.network)') process.exit(1) } - // Handle is just the PDS hostname - opts.handle = new URL(opts.pds).host - return opts } @@ -288,14 +289,17 @@ function base64UrlEncode(bytes) { async function createGenesisOperation(opts) { const { didKey, handle, pdsUrl, cryptoKey } = opts - // Handle is already the full hostname + // Build full handle: subdomain.pds-hostname + const pdsHost = new URL(pdsUrl).host + const fullHandle = `${handle}.${pdsHost}` + const operation = { type: 'plc_operation', rotationKeys: [didKey], verificationMethods: { atproto: didKey }, - alsoKnownAs: [`at://${handle}`], + alsoKnownAs: [`at://${fullHandle}`], services: { atproto_pds: { type: 'AtprotoPersonalDataServer', @@ -308,7 +312,7 @@ async function createGenesisOperation(opts) { // Sign the operation operation.sig = await signPlcOperation(operation, cryptoKey) - return { operation, handle } + return { operation, fullHandle } } async function deriveDidFromOperation(operation) { @@ -387,6 +391,27 @@ async function initializePds(pdsUrl, did, privateKeyHex, handle) { return response.json() } +// === HANDLE REGISTRATION === + +async function registerHandle(pdsUrl, handle, did) { + const url = `${pdsUrl}/register-handle` + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ handle, did }) + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Handle registration failed: ${response.status} ${text}`) + } + + return true +} + // === RELAY NOTIFICATION === async function notifyRelay(relayUrl, pdsHostname) { @@ -437,7 +462,7 @@ async function main() { // Step 2: Create genesis operation console.log('Creating PLC genesis operation...') - const { operation, handle } = await createGenesisOperation({ + const { operation, fullHandle } = await createGenesisOperation({ didKey, handle: opts.handle, pdsUrl: opts.pds, @@ -445,7 +470,7 @@ async function main() { }) const did = await deriveDidFromOperation(operation) console.log(` DID: ${did}`) - console.log(` Handle: ${handle}`) + console.log(` Handle: ${fullHandle}`) console.log('') // Step 3: Register with PLC directory @@ -457,10 +482,16 @@ async function main() { // Step 4: Initialize PDS console.log(`Initializing PDS at ${opts.pds}...`) const privateKeyHex = bytesToHex(keyPair.privateKey) - await initializePds(opts.pds, did, privateKeyHex, handle) + await initializePds(opts.pds, did, privateKeyHex, fullHandle) console.log(' PDS initialized!') console.log('') + // Step 4b: Register handle -> DID mapping + console.log(`Registering handle mapping...`) + await registerHandle(opts.pds, opts.handle, did) + console.log(` Handle ${opts.handle} -> ${did}`) + console.log('') + // Step 5: Notify relay const pdsHostname = new URL(opts.pds).host console.log(`Notifying relay at ${opts.relayUrl}...`) @@ -472,7 +503,7 @@ async function main() { // Step 6: Save credentials const credentials = { - handle, + handle: fullHandle, did, privateKeyHex: bytesToHex(keyPair.privateKey), didKey, @@ -480,13 +511,13 @@ async function main() { createdAt: new Date().toISOString() } - const credentialsFile = `./credentials.json` + const credentialsFile = `./credentials-${opts.handle}.json` saveCredentials(credentialsFile, credentials) // Final output console.log('Setup Complete!') console.log('===============') - console.log(`Handle: ${handle}`) + console.log(`Handle: ${fullHandle}`) console.log(`DID: ${did}`) console.log(`PDS: ${opts.pds}`) console.log('') diff --git a/src/pds.js b/src/pds.js index 9cbdc2a..01da807 100644 --- a/src/pds.js +++ b/src/pds.js @@ -878,6 +878,13 @@ const pdsRoutes = { '/get-registered-dids': { handler: (pds, req, url) => pds.handleGetRegisteredDids() }, + '/register-handle': { + method: 'POST', + handler: (pds, req, url) => pds.handleRegisterHandle(req) + }, + '/resolve-handle': { + handler: (pds, req, url) => pds.handleResolveHandle(url) + }, '/repo-info': { handler: (pds, req, url) => pds.handleRepoInfo() }, @@ -1255,6 +1262,31 @@ export class PersonalDataServer { return Response.json({ dids: registeredDids }) } + async handleRegisterHandle(request) { + const body = await request.json() + const { handle, did } = body + if (!handle || !did) { + return Response.json({ error: 'missing handle or did' }, { status: 400 }) + } + const handleMap = await this.state.storage.get('handleMap') || {} + handleMap[handle] = did + await this.state.storage.put('handleMap', handleMap) + return Response.json({ ok: true }) + } + + async handleResolveHandle(url) { + const handle = url.searchParams.get('handle') + if (!handle) { + return Response.json({ error: 'missing handle' }, { status: 400 }) + } + const handleMap = await this.state.storage.get('handleMap') || {} + const did = handleMap[handle] + if (!did) { + return Response.json({ error: 'handle not found' }, { status: 404 }) + } + return Response.json({ did }) + } + async handleRepoInfo() { const head = await this.state.storage.get('head') const rev = await this.state.storage.get('rev') @@ -1536,21 +1568,58 @@ export default { } } +// Extract subdomain from hostname (e.g., "alice" from "alice.foo.workers.dev") +function getSubdomain(hostname) { + const parts = hostname.split('.') + // workers.dev domains: [subdomain?].[worker-name].[account].workers.dev + // If more than 4 parts, first part(s) are user subdomain + if (parts.length > 4 && parts.slice(-2).join('.') === 'workers.dev') { + return parts.slice(0, -4).join('.') + } + // Custom domains: check if there's a subdomain before the base + // For now, assume no subdomain on custom domains + return null +} + async function handleRequest(request, env) { const url = new URL(request.url) + const subdomain = getSubdomain(url.hostname) - // Endpoints that don't require ?did= param (for relay/federation) - if (url.pathname === '/.well-known/atproto-did' || - url.pathname === '/xrpc/com.atproto.server.describeServer') { - const did = url.searchParams.get('did') || 'default' - const id = env.PDS.idFromName(did) - const pds = env.PDS.get(id) - // Pass hostname for describeServer + // Handle resolution via subdomain + if (url.pathname === '/.well-known/atproto-did') { + if (!subdomain) { + // Bare domain - no user here + return new Response('No user at bare domain. Use a subdomain like alice.' + url.hostname, { status: 404 }) + } + // Look up handle -> DID in default DO + const defaultId = env.PDS.idFromName('default') + const defaultPds = env.PDS.get(defaultId) + const resolveRes = await defaultPds.fetch( + new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(subdomain)}`) + ) + if (!resolveRes.ok) { + return new Response('Handle not found', { status: 404 }) + } + const { did } = await resolveRes.json() + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) + } + + // describeServer - works on bare domain + if (url.pathname === '/xrpc/com.atproto.server.describeServer') { + const defaultId = env.PDS.idFromName('default') + const defaultPds = env.PDS.get(defaultId) const newReq = new Request(request.url, { method: request.method, headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } }) - return pds.fetch(newReq) + return defaultPds.fetch(newReq) + } + + // Handle registration routes - go to default DO + if (url.pathname === '/register-handle' || url.pathname === '/resolve-handle') { + const defaultId = env.PDS.idFromName('default') + const defaultPds = env.PDS.get(defaultId) + return defaultPds.fetch(request) } // subscribeRepos WebSocket - route to default instance for firehose -- 2.51.2