A zero-dependency AT Protocol Personal Data Server written in JavaScript
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add PDS-to-PDS inbox (xyz.fake.inbox.*)

- inbox tables: messages, requests, accepted, blocked
- DID resolution via plc.directory with P-256 key extraction
- service auth JWT verification against resolved public keys
- handlers: send, list, listRequests, accept, reject

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

zzstoatzz 0c389ce9 b6a21e1d

+451 -1
+450
src/pds.js
··· 1017 1017 return minLen; 1018 1018 } 1019 1019 1020 + // โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 1021 + // โ•‘ DID RESOLUTION โ•‘ 1022 + // โ•‘ Resolve DIDs via PLC directory to get public keys โ•‘ 1023 + // โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 1024 + 1025 + const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; 1026 + 1027 + /** 1028 + * Decode base58btc string to bytes 1029 + * @param {string} str - base58btc encoded string (without 'z' prefix) 1030 + * @returns {Uint8Array} 1031 + */ 1032 + function base58Decode(str) { 1033 + const bytes = []; 1034 + for (const char of str) { 1035 + const idx = BASE58_ALPHABET.indexOf(char); 1036 + if (idx === -1) throw new Error(`Invalid base58 character: ${char}`); 1037 + let carry = idx; 1038 + for (let i = 0; i < bytes.length; i++) { 1039 + carry += bytes[i] * 58; 1040 + bytes[i] = carry & 0xff; 1041 + carry >>= 8; 1042 + } 1043 + while (carry > 0) { 1044 + bytes.push(carry & 0xff); 1045 + carry >>= 8; 1046 + } 1047 + } 1048 + // Handle leading zeros 1049 + for (const char of str) { 1050 + if (char !== '1') break; 1051 + bytes.push(0); 1052 + } 1053 + return new Uint8Array(bytes.reverse()); 1054 + } 1055 + 1056 + /** 1057 + * Resolve a DID via PLC directory and extract public key 1058 + * @param {string} did - DID to resolve (did:plc:...) 1059 + * @returns {Promise<{publicKey: Uint8Array, didDoc: object} | null>} 1060 + */ 1061 + async function resolveDid(did) { 1062 + if (!did.startsWith('did:plc:')) { 1063 + // Only did:plc supported for now 1064 + return null; 1065 + } 1066 + 1067 + try { 1068 + const res = await fetch(`https://plc.directory/${did}`); 1069 + if (!res.ok) return null; 1070 + 1071 + const didDoc = await res.json(); 1072 + 1073 + // Find the atproto verification method 1074 + const vm = didDoc.verificationMethod?.find( 1075 + (m) => m.id === `${did}#atproto` || m.id.endsWith('#atproto') 1076 + ); 1077 + if (!vm?.publicKeyMultibase) return null; 1078 + 1079 + // Decode multibase key (z = base58btc prefix) 1080 + const multibase = vm.publicKeyMultibase; 1081 + if (!multibase.startsWith('z')) { 1082 + return null; // Only base58btc supported 1083 + } 1084 + 1085 + const decoded = base58Decode(multibase.slice(1)); 1086 + 1087 + // P-256 public key multicodec prefix is 0x80 0x24 1088 + // Compressed P-256 key is 33 bytes after the 2-byte prefix 1089 + if (decoded[0] === 0x80 && decoded[1] === 0x24) { 1090 + const publicKey = decoded.slice(2); 1091 + return { publicKey, didDoc }; 1092 + } 1093 + 1094 + return null; 1095 + } catch (e) { 1096 + console.error('DID resolution failed:', e); 1097 + return null; 1098 + } 1099 + } 1100 + 1101 + /** 1102 + * Verify a service auth JWT against the issuer's resolved public key 1103 + * @param {string} jwt - JWT to verify 1104 + * @returns {Promise<{valid: boolean, payload: object|null, error: string|null}>} 1105 + */ 1106 + async function verifyInboxJwt(jwt) { 1107 + try { 1108 + const [headerB64, payloadB64, sigB64] = jwt.split('.'); 1109 + if (!headerB64 || !payloadB64 || !sigB64) { 1110 + return { valid: false, payload: null, error: 'malformed JWT' }; 1111 + } 1112 + 1113 + // Decode payload 1114 + const payload = JSON.parse( 1115 + new TextDecoder().decode(base64UrlDecode(payloadB64)) 1116 + ); 1117 + 1118 + // Check expiration 1119 + const now = Math.floor(Date.now() / 1000); 1120 + if (payload.exp && payload.exp < now) { 1121 + return { valid: false, payload, error: 'expired' }; 1122 + } 1123 + 1124 + // Resolve issuer DID to get public key 1125 + const resolved = await resolveDid(payload.iss); 1126 + if (!resolved) { 1127 + return { valid: false, payload, error: 'could not resolve issuer DID' }; 1128 + } 1129 + 1130 + // Decompress P-256 public key (33 bytes โ†’ 65 bytes) 1131 + const compressed = resolved.publicKey; 1132 + const p = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n; 1133 + const b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; 1134 + 1135 + const prefix = compressed[0]; 1136 + const xBytes = compressed.slice(1, 33); 1137 + let x = 0n; 1138 + for (const byte of xBytes) x = (x << 8n) | BigInt(byte); 1139 + 1140 + // yยฒ = xยณ - 3x + b (mod p) 1141 + const rhs = (((x ** 3n % p) - 3n * x % p + b) % p + p) % p; 1142 + 1143 + // Tonelli-Shanks for p โ‰ก 3 (mod 4) 1144 + let y = modPow(rhs, (p + 1n) / 4n, p); 1145 + 1146 + const yIsEven = (y & 1n) === 0n; 1147 + const wantEven = prefix === 0x02; 1148 + if (yIsEven !== wantEven) y = p - y; 1149 + 1150 + const uncompressed = new Uint8Array(65); 1151 + uncompressed[0] = 0x04; 1152 + let xTemp = x, yTemp = y; 1153 + for (let i = 31; i >= 0; i--) { uncompressed[1 + i] = Number(xTemp & 0xffn); xTemp >>= 8n; } 1154 + for (let i = 31; i >= 0; i--) { uncompressed[33 + i] = Number(yTemp & 0xffn); yTemp >>= 8n; } 1155 + 1156 + // Import public key 1157 + const cryptoKey = await crypto.subtle.importKey( 1158 + 'raw', 1159 + uncompressed, 1160 + { name: 'ECDSA', namedCurve: 'P-256' }, 1161 + false, 1162 + ['verify'] 1163 + ); 1164 + 1165 + // Verify signature 1166 + const sigBytes = base64UrlDecode(sigB64); 1167 + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 1168 + const valid = await crypto.subtle.verify( 1169 + { name: 'ECDSA', hash: 'SHA-256' }, 1170 + cryptoKey, 1171 + sigBytes, 1172 + data 1173 + ); 1174 + 1175 + return { valid, payload, error: valid ? null : 'bad signature' }; 1176 + } catch (e) { 1177 + return { valid: false, payload: null, error: e.message }; 1178 + } 1179 + } 1180 + 1181 + /** 1182 + * Modular exponentiation for BigInt 1183 + * @param {bigint} base 1184 + * @param {bigint} exp 1185 + * @param {bigint} mod 1186 + * @returns {bigint} 1187 + */ 1188 + function modPow(base, exp, mod) { 1189 + let result = 1n; 1190 + base = base % mod; 1191 + while (exp > 0n) { 1192 + if (exp & 1n) result = (result * base) % mod; 1193 + exp >>= 1n; 1194 + base = (base * base) % mod; 1195 + } 1196 + return result; 1197 + } 1198 + 1020 1199 class MST { 1021 1200 /** @param {SqlStorage} sql */ 1022 1201 constructor(sql) { ··· 1460 1639 '/xrpc/com.atproto.sync.subscribeRepos': { 1461 1640 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), 1462 1641 }, 1642 + // PDS-to-PDS inbox (xyz.fake.inbox.*) 1643 + '/xrpc/xyz.fake.inbox.send': { 1644 + method: 'POST', 1645 + handler: (pds, req, _url) => pds.handleInboxSend(req), 1646 + }, 1647 + '/xrpc/xyz.fake.inbox.list': { 1648 + handler: (pds, _req, _url) => pds.handleInboxList(), 1649 + }, 1650 + '/xrpc/xyz.fake.inbox.listRequests': { 1651 + handler: (pds, _req, _url) => pds.handleInboxListRequests(), 1652 + }, 1653 + '/xrpc/xyz.fake.inbox.accept': { 1654 + method: 'POST', 1655 + handler: (pds, req, _url) => pds.handleInboxAccept(req), 1656 + }, 1657 + '/xrpc/xyz.fake.inbox.reject': { 1658 + method: 'POST', 1659 + handler: (pds, req, _url) => pds.handleInboxReject(req), 1660 + }, 1463 1661 }; 1464 1662 1465 1663 // โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— ··· 1525 1723 CREATE INDEX IF NOT EXISTS idx_record_blob_uri ON record_blob(recordUri); 1526 1724 1527 1725 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1726 + 1727 + -- PDS-to-PDS inbox (xyz.fake.inbox.*) 1728 + CREATE TABLE IF NOT EXISTS inbox_messages ( 1729 + id INTEGER PRIMARY KEY AUTOINCREMENT, 1730 + fromDid TEXT NOT NULL, 1731 + text TEXT NOT NULL, 1732 + createdAt TEXT NOT NULL 1733 + ); 1734 + 1735 + CREATE TABLE IF NOT EXISTS inbox_requests ( 1736 + fromDid TEXT PRIMARY KEY, 1737 + text TEXT NOT NULL, 1738 + createdAt TEXT NOT NULL 1739 + ); 1740 + 1741 + CREATE TABLE IF NOT EXISTS inbox_accepted ( 1742 + did TEXT PRIMARY KEY 1743 + ); 1744 + 1745 + CREATE TABLE IF NOT EXISTS inbox_blocked ( 1746 + did TEXT PRIMARY KEY 1747 + ); 1748 + 1749 + CREATE INDEX IF NOT EXISTS idx_inbox_messages_from ON inbox_messages(fromDid); 1528 1750 `); 1529 1751 } 1530 1752 ··· 3022 3244 return new Response(null, { status: 101, webSocket: client }); 3023 3245 } 3024 3246 3247 + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 3248 + // PDS-to-PDS Inbox (xyz.fake.inbox.*) 3249 + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 3250 + 3251 + /** 3252 + * Receive a message from another PDS 3253 + * Verifies service auth JWT via PLC resolution 3254 + * @param {Request} request 3255 + */ 3256 + async handleInboxSend(request) { 3257 + // Extract JWT from Authorization header 3258 + const authHeader = request.headers.get('Authorization'); 3259 + if (!authHeader?.startsWith('Bearer ')) { 3260 + return errorResponse('Unauthorized', 'missing authorization', 401); 3261 + } 3262 + const jwt = authHeader.slice(7); 3263 + 3264 + // Verify JWT via PLC resolution 3265 + const { valid, payload, error } = await verifyInboxJwt(jwt); 3266 + if (!valid) { 3267 + return errorResponse('Unauthorized', `auth failed: ${error}`, 401); 3268 + } 3269 + 3270 + // Check audience matches this PDS 3271 + const myDid = await this.getDid(); 3272 + if (payload.aud !== myDid) { 3273 + return errorResponse('Unauthorized', 'wrong audience', 401); 3274 + } 3275 + 3276 + const senderDid = payload.iss; 3277 + const body = await request.json(); 3278 + const text = body.text; 3279 + 3280 + if (!text || typeof text !== 'string') { 3281 + return errorResponse('InvalidRequest', 'missing text', 400); 3282 + } 3283 + 3284 + // Check if blocked 3285 + const blocked = this.sql 3286 + .exec('SELECT did FROM inbox_blocked WHERE did = ?', senderDid) 3287 + .toArray(); 3288 + if (blocked.length > 0) { 3289 + return errorResponse('Forbidden', 'blocked', 403); 3290 + } 3291 + 3292 + // Check if accepted 3293 + const accepted = this.sql 3294 + .exec('SELECT did FROM inbox_accepted WHERE did = ?', senderDid) 3295 + .toArray(); 3296 + 3297 + const now = new Date().toISOString(); 3298 + 3299 + if (accepted.length > 0) { 3300 + // Deliver directly to inbox 3301 + this.sql.exec( 3302 + 'INSERT INTO inbox_messages (fromDid, text, createdAt) VALUES (?, ?, ?)', 3303 + senderDid, 3304 + text, 3305 + now 3306 + ); 3307 + return Response.json({ status: 'delivered' }); 3308 + } 3309 + 3310 + // Check if already has pending request 3311 + const pending = this.sql 3312 + .exec('SELECT fromDid FROM inbox_requests WHERE fromDid = ?', senderDid) 3313 + .toArray(); 3314 + 3315 + if (pending.length > 0) { 3316 + return Response.json({ status: 'pending' }); 3317 + } 3318 + 3319 + // Create new request 3320 + this.sql.exec( 3321 + 'INSERT INTO inbox_requests (fromDid, text, createdAt) VALUES (?, ?, ?)', 3322 + senderDid, 3323 + text, 3324 + now 3325 + ); 3326 + return Response.json({ status: 'request_created' }); 3327 + } 3328 + 3329 + /** 3330 + * List inbox messages (auth verified at routing level) 3331 + */ 3332 + async handleInboxList() { 3333 + const messages = this.sql 3334 + .exec('SELECT id, fromDid, text, createdAt FROM inbox_messages ORDER BY createdAt DESC LIMIT 100') 3335 + .toArray(); 3336 + 3337 + return Response.json({ messages }); 3338 + } 3339 + 3340 + /** 3341 + * List pending requests (auth verified at routing level) 3342 + */ 3343 + async handleInboxListRequests() { 3344 + const requests = this.sql 3345 + .exec('SELECT fromDid, text, createdAt FROM inbox_requests ORDER BY createdAt DESC') 3346 + .toArray(); 3347 + 3348 + return Response.json({ requests }); 3349 + } 3350 + 3351 + /** 3352 + * Accept a pending request (auth verified at routing level) 3353 + * @param {Request} request 3354 + */ 3355 + async handleInboxAccept(request) { 3356 + const body = await request.json(); 3357 + const senderDid = body.did; 3358 + 3359 + if (!senderDid) { 3360 + return errorResponse('InvalidRequest', 'missing did', 400); 3361 + } 3362 + 3363 + // Get pending request 3364 + const pending = this.sql 3365 + .exec('SELECT fromDid, text, createdAt FROM inbox_requests WHERE fromDid = ?', senderDid) 3366 + .toArray(); 3367 + 3368 + if (pending.length === 0) { 3369 + return errorResponse('NotFound', 'no pending request', 404); 3370 + } 3371 + 3372 + const req = pending[0]; 3373 + 3374 + // Move to inbox 3375 + this.sql.exec( 3376 + 'INSERT INTO inbox_messages (fromDid, text, createdAt) VALUES (?, ?, ?)', 3377 + req.fromDid, 3378 + req.text, 3379 + req.createdAt 3380 + ); 3381 + 3382 + // Mark as accepted 3383 + this.sql.exec('INSERT OR IGNORE INTO inbox_accepted (did) VALUES (?)', senderDid); 3384 + 3385 + // Remove request 3386 + this.sql.exec('DELETE FROM inbox_requests WHERE fromDid = ?', senderDid); 3387 + 3388 + return Response.json({ status: 'accepted' }); 3389 + } 3390 + 3391 + /** 3392 + * Reject a pending request and block sender (auth verified at routing level) 3393 + * @param {Request} request 3394 + */ 3395 + async handleInboxReject(request) { 3396 + const body = await request.json(); 3397 + const senderDid = body.did; 3398 + 3399 + if (!senderDid) { 3400 + return errorResponse('InvalidRequest', 'missing did', 400); 3401 + } 3402 + 3403 + // Remove any pending request 3404 + this.sql.exec('DELETE FROM inbox_requests WHERE fromDid = ?', senderDid); 3405 + 3406 + // Block sender 3407 + this.sql.exec('INSERT OR IGNORE INTO inbox_blocked (did) VALUES (?)', senderDid); 3408 + 3409 + return Response.json({ status: 'rejected' }); 3410 + } 3411 + 3412 + /** 3413 + * Verify session auth from Authorization header 3414 + * @param {Request} request 3415 + * @returns {Promise<{did: string} | null>} 3416 + */ 3417 + async verifySessionAuth(request) { 3418 + const authHeader = request.headers.get('Authorization'); 3419 + if (!authHeader?.startsWith('Bearer ')) { 3420 + return null; 3421 + } 3422 + const token = authHeader.slice(7); 3423 + 3424 + try { 3425 + const result = await verifyAccessJwt(token, this.env.JWT_SECRET); 3426 + if (!result.valid) return null; 3427 + return { did: result.payload.sub }; 3428 + } catch { 3429 + return null; 3430 + } 3431 + } 3432 + 3025 3433 /** @param {Request} request */ 3026 3434 async fetch(request) { 3027 3435 const url = new URL(request.url); ··· 3435 3843 ]; 3436 3844 if (repoWriteEndpoints.includes(url.pathname)) { 3437 3845 return handleAuthenticatedRepoWrite(request, env); 3846 + } 3847 + 3848 + // PDS-to-PDS inbox: send routes to recipient (from JWT aud) 3849 + if (url.pathname === '/xrpc/xyz.fake.inbox.send') { 3850 + // Parse JWT to get recipient DID from aud field 3851 + const authHeader = request.headers.get('Authorization'); 3852 + if (!authHeader?.startsWith('Bearer ')) { 3853 + return errorResponse('Unauthorized', 'missing authorization', 401); 3854 + } 3855 + const jwt = authHeader.slice(7); 3856 + const parts = jwt.split('.'); 3857 + if (parts.length !== 3) { 3858 + return errorResponse('Unauthorized', 'invalid jwt format', 401); 3859 + } 3860 + try { 3861 + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); 3862 + const recipientDid = payload.aud; 3863 + if (!recipientDid?.startsWith('did:')) { 3864 + return errorResponse('InvalidRequest', 'invalid recipient did', 400); 3865 + } 3866 + // Route to recipient's DO 3867 + const id = env.PDS.idFromName(recipientDid); 3868 + const pds = env.PDS.get(id); 3869 + return pds.fetch(request); 3870 + } catch { 3871 + return errorResponse('Unauthorized', 'invalid jwt payload', 401); 3872 + } 3873 + } 3874 + 3875 + // PDS-to-PDS inbox: list/accept/reject route to authenticated user 3876 + const inboxAuthEndpoints = [ 3877 + '/xrpc/xyz.fake.inbox.list', 3878 + '/xrpc/xyz.fake.inbox.listRequests', 3879 + '/xrpc/xyz.fake.inbox.accept', 3880 + '/xrpc/xyz.fake.inbox.reject', 3881 + ]; 3882 + if (inboxAuthEndpoints.includes(url.pathname)) { 3883 + const auth = await requireAuth(request, env); 3884 + if ('error' in auth) return auth.error; 3885 + const id = env.PDS.idFromName(auth.did); 3886 + const pds = env.PDS.get(id); 3887 + return pds.fetch(request); 3438 3888 } 3439 3889 3440 3890 // Health check endpoint
+1 -1
wrangler.toml
··· 1 - name = "atproto-pds" 1 + name = "pds-message-demo" 2 2 main = "src/pds.js" 3 3 compatibility_date = "2024-01-01" 4 4