An in browser local PDS localpds.at
20

Configure Feed

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

Latest changes - adding webhook

authored by

Niall Bunting and committed by
Niall Bunting
(May 29, 2026, 9:00 AM +0100) d424e6ab fa6fe474

+1334 -496
+862
atproto-repo.js
··· 1 + /** 2 + * atproto-repo.js 3 + * ================ 4 + * A fully spec-compliant ATProto repository engine for use inside a Chrome 5 + * extension background service worker (no Node.js, no npm, pure WebCrypto). 6 + * 7 + * Implements: 8 + * - DAG-CBOR encoding (DRISL subset) with deterministic map key ordering 9 + * - CIDv1 (dag-cbor codec 0x71, sha2-256, base32-lower 'b' prefix) 10 + * - TID generation (53-bit µs timestamp + 10-bit clock-id, base32-sortable) 11 + * - MST (Merkle Search Tree) — depth via SHA-256 leading-zero bits ÷ 2, 12 + * fanout 4, prefix-key compression, correct node schema {l, e[{p,k,v,t}]} 13 + * - Signed commits (unsigned CBOR → SHA-256 → ES256 → sig bytes in commit) 14 + * - In-extension block store backed by chrome.storage.local 15 + * - CAR v1 export for com.atproto.sync.* 16 + * 17 + * Drop this file into your extension and import the exported symbols. 18 + * 19 + * Usage (replace your mock handlers with these): 20 + * 21 + * import { Repo } from './atproto-repo.js'; 22 + * const repo = await Repo.open(userDid, privateKey); 23 + * const { uri, cid, commit } = await repo.createRecord(collection, rkey, record); 24 + * const carBytes = await repo.exportCAR(); 25 + */ 26 + 27 + // --------------------------------------------------------------------------- 28 + // 1. DAG-CBOR encoder 29 + // Spec: https://ipld.io/specs/codecs/dag-cbor/spec/ 30 + // atproto DRISL rules: 31 + // • maps: keys sorted by UTF-8 byte-length first, then lexicographic 32 + // (this is the dag-cbor "length-first" canon used by all atproto libs) 33 + // • CID links: CBOR tag 42 wrapping bytes [0x00, ...rawCidBytes] 34 + // • no floats; null = 0xf6; true = 0xf5; false = 0xf4 35 + // --------------------------------------------------------------------------- 36 + 37 + /** 38 + * A thin wrapper so we can tell a CID-link apart from a plain Uint8Array 39 + * when passing objects to encodeDagCbor(). 40 + */ 41 + export class CidLink { 42 + constructor(cidBytes) { 43 + // cidBytes is the raw binary CID (not base32-encoded) 44 + this.bytes = cidBytes; 45 + } 46 + } 47 + 48 + function writeUint(buf, major, value) { 49 + const m = major << 5; 50 + if (value <= 23) { 51 + buf.push(m | value); 52 + } else if (value <= 0xff) { 53 + buf.push(m | 24, value); 54 + } else if (value <= 0xffff) { 55 + buf.push(m | 25, (value >> 8) & 0xff, value & 0xff); 56 + } else if (value <= 0xffffffff) { 57 + buf.push(m | 26, 58 + (value >>> 24) & 0xff, (value >>> 16) & 0xff, 59 + (value >>> 8) & 0xff, value & 0xff); 60 + } else { 61 + // 64-bit — use BigInt path 62 + const hi = Math.floor(value / 0x100000000); 63 + const lo = value >>> 0; 64 + buf.push(m | 27, 65 + (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, 66 + (hi >>> 8) & 0xff, hi & 0xff, 67 + (lo >>> 24) & 0xff, (lo >>> 16) & 0xff, 68 + (lo >>> 8) & 0xff, lo & 0xff); 69 + } 70 + } 71 + 72 + function encodeDagCborInto(value, buf) { 73 + if (value === null || value === undefined) { 74 + buf.push(0xf6); // null 75 + return; 76 + } 77 + if (value === true) { buf.push(0xf5); return; } 78 + if (value === false) { buf.push(0xf4); return; } 79 + 80 + if (value instanceof CidLink) { 81 + // Tag 42 82 + const inner = new Uint8Array([0x00, ...value.bytes]); // multibase identity prefix 83 + buf.push(0xd8, 0x2a); // CBOR tag 42 (0xd8 = tag major+24, 0x2a=42) 84 + writeUint(buf, 2, inner.length); // byte-string 85 + for (const b of inner) buf.push(b); 86 + return; 87 + } 88 + 89 + if (value instanceof Uint8Array) { 90 + writeUint(buf, 2, value.length); 91 + for (const b of value) buf.push(b); 92 + return; 93 + } 94 + 95 + if (typeof value === 'string') { 96 + const bytes = new TextEncoder().encode(value); 97 + writeUint(buf, 3, bytes.length); 98 + for (const b of bytes) buf.push(b); 99 + return; 100 + } 101 + 102 + if (typeof value === 'number') { 103 + if (!Number.isInteger(value)) throw new Error('DAG-CBOR: floats not allowed'); 104 + if (value >= 0) { 105 + writeUint(buf, 0, value); 106 + } else { 107 + writeUint(buf, 1, -1 - value); 108 + } 109 + return; 110 + } 111 + 112 + if (typeof value === 'bigint') { 113 + if (value >= 0n) { 114 + // major 0 — write as 8-byte uint 115 + const hi = Number(value >> 32n); 116 + const lo = Number(value & 0xffffffffn); 117 + buf.push(0x1b, 118 + (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, 119 + (hi >>> 8) & 0xff, hi & 0xff, 120 + (lo >>> 24) & 0xff, (lo >>> 16) & 0xff, 121 + (lo >>> 8) & 0xff, lo & 0xff); 122 + } else { 123 + const n = -1n - value; 124 + const hi = Number(n >> 32n); 125 + const lo = Number(n & 0xffffffffn); 126 + buf.push(0x3b, 127 + (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, 128 + (hi >>> 8) & 0xff, hi & 0xff, 129 + (lo >>> 24) & 0xff, (lo >>> 16) & 0xff, 130 + (lo >>> 8) & 0xff, lo & 0xff); 131 + } 132 + return; 133 + } 134 + 135 + if (Array.isArray(value)) { 136 + writeUint(buf, 4, value.length); 137 + for (const item of value) encodeDagCborInto(item, buf); 138 + return; 139 + } 140 + 141 + if (typeof value === 'object') { 142 + // dag-cbor canonical map key order: 143 + // sort by UTF-8 encoded byte-length first, then lexicographically 144 + const enc = new TextEncoder(); 145 + const entries = Object.entries(value); 146 + entries.sort(([a], [b]) => { 147 + const ab = enc.encode(a); 148 + const bb = enc.encode(b); 149 + if (ab.length !== bb.length) return ab.length - bb.length; 150 + for (let i = 0; i < ab.length; i++) { 151 + if (ab[i] !== bb[i]) return ab[i] - bb[i]; 152 + } 153 + return 0; 154 + }); 155 + writeUint(buf, 5, entries.length); 156 + for (const [k, v] of entries) { 157 + encodeDagCborInto(k, buf); 158 + encodeDagCborInto(v, buf); 159 + } 160 + return; 161 + } 162 + 163 + throw new Error(`DAG-CBOR: unsupported type ${typeof value}`); 164 + } 165 + 166 + export function encodeDagCbor(value) { 167 + const buf = []; 168 + encodeDagCborInto(value, buf); 169 + return new Uint8Array(buf); 170 + } 171 + 172 + // --------------------------------------------------------------------------- 173 + // 2. CID helpers 174 + // Blessed format: CIDv1 + dag-cbor (0x71) + sha2-256 + base32lower ('b') 175 + // For commit objects themselves, codec is also 0x71 (dag-cbor). 176 + // For record leaf CIDs, same. 177 + // --------------------------------------------------------------------------- 178 + 179 + const BASE32_LOWER = 'abcdefghijklmnopqrstuvwxyz234567'; 180 + 181 + function base32Encode(bytes) { 182 + let bits = 0, value = 0, out = ''; 183 + for (const byte of bytes) { 184 + value = (value << 8) | byte; 185 + bits += 8; 186 + while (bits >= 5) { 187 + bits -= 5; 188 + out += BASE32_LOWER[(value >>> bits) & 0x1f]; 189 + } 190 + } 191 + if (bits > 0) out += BASE32_LOWER[(value << (5 - bits)) & 0x1f]; 192 + return out; 193 + } 194 + 195 + /** 196 + * Build a raw CIDv1 binary from a SHA-256 digest. 197 + * Layout: [0x01, codec_varint..., 0x12, 0x20, ...digest] 198 + */ 199 + function makeCidBytes(digest, codec = 0x71) { 200 + // codec as unsigned varint (single byte for 0x71 = 113 which fits in 7 bits... wait: 0x71 = 113 > 127 so needs 2 bytes) 201 + // varint encoding: 0x71 = 0b01110001, but varints are little-endian 7-bit groups 202 + // 113 in varint: 113 < 128 so single byte 0x71 203 + // Actually 0x71 = 113 decimal, which IS < 128, so it's a single varint byte. 204 + const codecVarint = codec < 0x80 ? [codec] : [0x80 | (codec & 0x7f), codec >> 7]; 205 + return new Uint8Array([ 206 + 0x01, // CIDv1 207 + ...codecVarint, // codec 208 + 0x12, // sha2-256 multihash code 209 + 0x20, // 32-byte digest 210 + ...digest 211 + ]); 212 + } 213 + 214 + export async function hashToCid(bytes, codec = 0x71) { 215 + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); 216 + const cidBytes = makeCidBytes(digest, codec); 217 + return { cidBytes, cidStr: 'b' + base32Encode(cidBytes) }; 218 + } 219 + 220 + /** Hash a JS object through DAG-CBOR then return its CID */ 221 + export async function objectToCid(obj, codec = 0x71) { 222 + const cbor = encodeDagCbor(obj); 223 + return hashToCid(cbor, codec); 224 + } 225 + 226 + // --------------------------------------------------------------------------- 227 + // 3. TID generation 228 + // Spec: https://atproto.com/specs/tid 229 + // 64-bit: [0][53-bit µs timestamp][10-bit clock-id] 230 + // Encoding: base32-sortable = '234567abcdefghijklmnopqrstuvwxyz' 231 + // Always 13 characters. 232 + // --------------------------------------------------------------------------- 233 + 234 + const TID_CHARSET = '234567abcdefghijklmnopqrstuvwxyz'; 235 + const CLOCK_ID = Math.floor(Math.random() * 1024); // 10-bit, fixed per session 236 + let lastTidMicros = 0n; 237 + 238 + export function generateTid() { 239 + let micros = BigInt(Date.now()) * 1000n; // ms → µs 240 + if (micros <= lastTidMicros) micros = lastTidMicros + 1n; 241 + lastTidMicros = micros; 242 + 243 + // Pack: bit63=0, bits62-10=micros (53 bits), bits9-0=clockId 244 + const packed = (micros << 10n) | BigInt(CLOCK_ID); 245 + 246 + // base32-sortable encode to exactly 13 chars (64 bits / 5 = 12.8, ceil=13) 247 + let n = packed; 248 + let out = ''; 249 + for (let i = 0; i < 13; i++) { 250 + out = TID_CHARSET[Number(n & 0x1fn)] + out; 251 + n >>= 5n; 252 + } 253 + return out; 254 + } 255 + 256 + // --------------------------------------------------------------------------- 257 + // 4. MST — Merkle Search Tree 258 + // Spec: https://atproto.com/specs/repository#mst-structure 259 + // 260 + // Key insight: the MST is purely a function of its current {key→cidBytes} 261 + // mapping. We rebuild it from scratch on every write (acceptable for a 262 + // single-user extension). For large repos we'd do incremental updates. 263 + // 264 + // Depth of a key = floor(leading_zero_bits(SHA256(keyBytes)) / 2) 265 + // Node schema (CBOR): 266 + // { l: CidLink|null, e: [ { p: int, k: Uint8Array, v: CidLink, t: CidLink|null } ] } 267 + // --------------------------------------------------------------------------- 268 + 269 + /** 270 + * Compute the MST depth for a repo path string (e.g. "app.bsky.graph.follow/3k...") 271 + */ 272 + async function mstDepth(path) { 273 + const keyBytes = new TextEncoder().encode(path); 274 + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', keyBytes)); 275 + let leadingZeroBits = 0; 276 + for (const byte of hash) { 277 + if (byte === 0) { leadingZeroBits += 8; continue; } 278 + // count leading zeros in this byte 279 + let b = byte; 280 + while ((b & 0x80) === 0) { leadingZeroBits++; b <<= 1; } 281 + break; 282 + } 283 + return Math.floor(leadingZeroBits / 2); 284 + } 285 + 286 + /** 287 + * Build a complete MST from a sorted array of {key, cidBytes} pairs. 288 + * Returns { rootCidBytes, rootCidStr, blocks: Map<cidStr, Uint8Array> } 289 + * where blocks contains every MST node's CBOR bytes keyed by its CID string. 290 + */ 291 + export async function buildMST(entries) { 292 + // entries: Array<{ key: string, cidBytes: Uint8Array }> 293 + // Must be sorted lexicographically by key (UTF-8 bytes). 294 + const blocks = new Map(); // cidStr → Uint8Array (CBOR of MST node) 295 + 296 + if (entries.length === 0) { 297 + // Empty tree: single node with l=null, e=[] 298 + const node = { l: null, e: [] }; 299 + const cbor = encodeDagCbor(node); 300 + const { cidBytes, cidStr } = await hashToCid(cbor); 301 + blocks.set(cidStr, cbor); 302 + return { rootCidBytes: cidBytes, rootCidStr: cidStr, blocks }; 303 + } 304 + 305 + // Annotate each entry with its depth 306 + const annotated = await Promise.all( 307 + entries.map(async ({ key, cidBytes }) => ({ 308 + key, cidBytes, depth: await mstDepth(key) 309 + })) 310 + ); 311 + 312 + // Recursive tree builder 313 + // Returns { nodeCidBytes, nodeCidStr } 314 + async function buildNode(items, currentDepth) { 315 + // items at this level: those whose depth >= currentDepth, in order. 316 + // Split into "this node's entries" (depth === currentDepth) and 317 + // sub-ranges that become child nodes at (currentDepth - 1). 318 + 319 + // We walk the items left-to-right. Items with depth >= currentDepth 320 + // are "at or above" this level. Items with depth < currentDepth 321 + // fall into a child subtree. 322 + 323 + // The node entries at exactly this depth go in `e[]`. 324 + // Between consecutive entries (and at the left edge), a child subtree 325 + // exists if there are items at depth < currentDepth in that gap. 326 + 327 + let leftChild = null; 328 + const nodeEntries = []; // { key, cidBytes, rightChildItems[] } 329 + let pendingSubItems = []; 330 + 331 + for (const item of items) { 332 + if (item.depth < currentDepth) { 333 + // Goes into a child subtree 334 + pendingSubItems.push(item); 335 + } else { 336 + // This entry lives at currentDepth (or higher — treat as this level) 337 + if (leftChild === null && pendingSubItems.length > 0) { 338 + leftChild = pendingSubItems; 339 + pendingSubItems = []; 340 + } else if (nodeEntries.length > 0) { 341 + nodeEntries[nodeEntries.length - 1].rightChildItems = pendingSubItems; 342 + pendingSubItems = []; 343 + } else { 344 + // items before the first entry at this depth become left child 345 + leftChild = pendingSubItems; 346 + pendingSubItems = []; 347 + } 348 + nodeEntries.push({ key: item.key, cidBytes: item.cidBytes, rightChildItems: [] }); 349 + } 350 + } 351 + // Any remaining pending items become the right child of the last entry 352 + if (nodeEntries.length > 0) { 353 + nodeEntries[nodeEntries.length - 1].rightChildItems = pendingSubItems; 354 + } else { 355 + // No entries at this depth — pass everything down one level 356 + // This is an "empty intermediate node" case 357 + leftChild = pendingSubItems; 358 + } 359 + 360 + // Recursively build left child 361 + let leftCidLink = null; 362 + if (leftChild && leftChild.length > 0) { 363 + const { nodeCidBytes } = await buildNode(leftChild, currentDepth - 1); 364 + leftCidLink = new CidLink(nodeCidBytes); 365 + } 366 + 367 + // Build the e[] array with prefix compression 368 + const eArray = []; 369 + let prevKey = new Uint8Array(0); 370 + for (const entry of nodeEntries) { 371 + const keyBytes = new TextEncoder().encode(entry.key); 372 + // Find common prefix length with previous key 373 + let p = 0; 374 + while (p < prevKey.length && p < keyBytes.length && prevKey[p] === keyBytes[p]) p++; 375 + const keySuffix = keyBytes.slice(p); 376 + 377 + // Build right child 378 + let tCidLink = null; 379 + if (entry.rightChildItems && entry.rightChildItems.length > 0) { 380 + const { nodeCidBytes } = await buildNode(entry.rightChildItems, currentDepth - 1); 381 + tCidLink = new CidLink(nodeCidBytes); 382 + } 383 + 384 + eArray.push({ p, k: keySuffix, v: new CidLink(entry.cidBytes), t: tCidLink }); 385 + prevKey = keyBytes; 386 + } 387 + 388 + // Encode this node 389 + const nodeObj = { l: leftCidLink, e: eArray }; 390 + const cbor = encodeDagCbor(nodeObj); 391 + const { cidBytes: nodeCidBytes, cidStr: nodeCidStr } = await hashToCid(cbor); 392 + blocks.set(nodeCidStr, cbor); 393 + 394 + // Also collect child blocks (they've been added recursively during buildNode calls above) 395 + return { nodeCidBytes, nodeCidStr }; 396 + } 397 + 398 + // Find the max depth to set as the root level 399 + const maxDepth = Math.max(...annotated.map(a => a.depth)); 400 + 401 + const { nodeCidBytes, nodeCidStr } = await buildNode(annotated, maxDepth); 402 + return { rootCidBytes: nodeCidBytes, rootCidStr: nodeCidStr, blocks }; 403 + } 404 + 405 + // --------------------------------------------------------------------------- 406 + // 5. Signed Commit 407 + // Process: 408 + // 1. Build unsigned commit object (all fields except sig) 409 + // 2. Encode as DAG-CBOR 410 + // 3. SHA-256 hash the CBOR bytes 411 + // 4. Sign the hash with ES256 (ECDSA P-256) ← WebCrypto signs the raw 412 + // message; subtle.sign with SHA-256 hashes internally, so pass the 413 + // CBOR bytes directly (not a pre-hash). 414 + // 5. Set sig = raw signature bytes (64 bytes, r||s format) 415 + // 6. Encode the full signed commit as DAG-CBOR → its CID is the commit CID 416 + // --------------------------------------------------------------------------- 417 + 418 + /** 419 + * Build and sign a commit object. 420 + * @param {string} did - account DID 421 + * @param {string} rev - TID string 422 + * @param {Uint8Array} dataCidBytes - CID bytes of the MST root 423 + * @param {Uint8Array|null} prevCidBytes - CID bytes of previous commit (null for first) 424 + * @param {CryptoKey} privateKey - ECDSA P-256 signing key 425 + * @returns {{ commitCbor, commitCidBytes, commitCidStr }} 426 + */ 427 + export async function buildSignedCommit(did, rev, dataCidBytes, prevCidBytes, privateKey) { 428 + const unsignedCommit = { 429 + did, 430 + version: 3, 431 + data: new CidLink(dataCidBytes), 432 + rev, 433 + prev: prevCidBytes ? new CidLink(prevCidBytes) : null, 434 + }; 435 + 436 + const unsignedCbor = encodeDagCbor(unsignedCommit); 437 + 438 + // Sign the raw CBOR bytes (SubtleCrypto will SHA-256 internally with ECDSA) 439 + const sigBuffer = await crypto.subtle.sign( 440 + { name: 'ECDSA', hash: 'SHA-256' }, 441 + privateKey, 442 + unsignedCbor 443 + ); 444 + // sigBuffer is IEEE P1363 format: r||s (64 bytes for P-256) 445 + const sig = new Uint8Array(sigBuffer); 446 + 447 + const signedCommit = { ...unsignedCommit, sig }; 448 + const commitCbor = encodeDagCbor(signedCommit); 449 + const { cidBytes: commitCidBytes, cidStr: commitCidStr } = await hashToCid(commitCbor); 450 + 451 + return { commitCbor, commitCidBytes, commitCidStr }; 452 + } 453 + 454 + // --------------------------------------------------------------------------- 455 + // 6. CAR v1 serialization 456 + // Header: CBOR({version:1, roots:[rootCid]}) prefixed with uvarint length 457 + // Blocks: uvarint(cidLen + dataLen) || cidBytes || dataBytes 458 + // --------------------------------------------------------------------------- 459 + 460 + function encodeUvarint(n) { 461 + const out = []; 462 + while (n > 0x7f) { 463 + out.push((n & 0x7f) | 0x80); 464 + n = Math.floor(n / 128); 465 + } 466 + out.push(n); 467 + return new Uint8Array(out); 468 + } 469 + 470 + /** 471 + * Encode a CID link (raw bytes) in its CBOR tag-42 form for the CAR header. 472 + * The CAR header uses dag-cbor with CID tag-42 to express the roots array. 473 + */ 474 + function encodeCarHeader(commitCidBytes) { 475 + // CBOR: { version: 1, roots: [ CidLink(commitCid) ] } 476 + const header = encodeDagCbor({ version: 1, roots: [new CidLink(commitCidBytes)] }); 477 + return header; 478 + } 479 + 480 + /** 481 + * Serialize blocks into a CAR v1 binary. 482 + * @param {Uint8Array} commitCidBytes 483 + * @param {Map<string, Uint8Array>} blocks - all blocks: cidStr → cborBytes 484 + * @param {Map<string, Uint8Array>} cidBytesMap - cidStr → raw CID bytes 485 + */ 486 + export function encodeCAR(commitCidBytes, blocks, cidBytesMap) { 487 + const parts = []; 488 + 489 + // Header 490 + const header = encodeCarHeader(commitCidBytes); 491 + parts.push(encodeUvarint(header.length)); 492 + parts.push(header); 493 + 494 + // Blocks 495 + for (const [cidStr, blockData] of blocks) { 496 + const cidBytes = cidBytesMap.get(cidStr); 497 + if (!cidBytes) continue; 498 + const sectionLen = cidBytes.length + blockData.length; 499 + parts.push(encodeUvarint(sectionLen)); 500 + parts.push(cidBytes); 501 + parts.push(blockData); 502 + } 503 + 504 + // Concatenate 505 + const totalLen = parts.reduce((s, p) => s + p.length, 0); 506 + const out = new Uint8Array(totalLen); 507 + let offset = 0; 508 + for (const p of parts) { out.set(p, offset); offset += p.length; } 509 + return out; 510 + } 511 + 512 + // --------------------------------------------------------------------------- 513 + // 7. Repo class — wraps storage, MST, commits 514 + // --------------------------------------------------------------------------- 515 + 516 + const STORE_KEY = 'atproto_repo_v2'; 517 + 518 + export class Repo { 519 + constructor(did, privateKey) { 520 + this.did = did; 521 + this.privateKey = privateKey; 522 + // records: Map<path, { cidBytes, cidStr, cborBytes }> 523 + this.records = new Map(); 524 + // current commit state 525 + this.currentCommitCidBytes = null; 526 + this.currentCommitCidStr = null; 527 + this.currentRev = null; 528 + } 529 + 530 + /** Load or create a repo from chrome.storage.local */ 531 + static async open(did, privateKey) { 532 + const repo = new Repo(did, privateKey); 533 + const stored = await chrome.storage.local.get(STORE_KEY); 534 + if (stored[STORE_KEY]) { 535 + const data = stored[STORE_KEY]; 536 + if (data.did === did) { 537 + // Restore records (stored as { path → { cidStr, cborHex } }) 538 + for (const [path, entry] of Object.entries(data.records || {})) { 539 + const cborBytes = hexToBytes(entry.cborHex); 540 + const { cidBytes, cidStr } = await hashToCid(cborBytes); 541 + repo.records.set(path, { cidBytes, cidStr, cborBytes }); 542 + } 543 + repo.currentCommitCidBytes = entry => entry; // placeholder — set below 544 + repo.currentCommitCidStr = data.commitCidStr || null; 545 + // Re-derive commit CID bytes from str 546 + if (data.commitCidStr) { 547 + repo.currentCommitCidBytes = base32DecodeToBytes(data.commitCidStr.slice(1)); // strip 'b' 548 + } 549 + repo.currentRev = data.rev || null; 550 + } 551 + } 552 + return repo; 553 + } 554 + 555 + async _persist() { 556 + const records = {}; 557 + for (const [path, entry] of this.records.entries()) { 558 + records[path] = { cborHex: bytesToHex(entry.cborBytes) }; 559 + } 560 + await chrome.storage.local.set({ 561 + [STORE_KEY]: { 562 + did: this.did, 563 + records, 564 + commitCidStr: this.currentCommitCidStr, 565 + rev: this.currentRev, 566 + } 567 + }); 568 + } 569 + 570 + /** 571 + * Apply a write (create/update/delete) and return the new commit state. 572 + */ 573 + async _applyWrite(op) { 574 + // op: { type: 'create'|'update'|'delete', collection, rkey, record? } 575 + const path = `${op.collection}/${op.rkey}`; 576 + 577 + if (op.type === 'delete') { 578 + this.records.delete(path); 579 + } else { 580 + // Encode record as DAG-CBOR 581 + const cborBytes = encodeDagCbor(op.record); 582 + const { cidBytes, cidStr } = await hashToCid(cborBytes); 583 + this.records.set(path, { cidBytes, cidStr, cborBytes }); 584 + } 585 + 586 + // Rebuild MST from sorted records 587 + const sortedEntries = Array.from(this.records.entries()) 588 + .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) 589 + .map(([key, { cidBytes }]) => ({ key, cidBytes })); 590 + 591 + const { rootCidBytes, rootCidStr, blocks: mstBlocks } = await buildMST(sortedEntries); 592 + 593 + // Build the cid-bytes map for MST nodes 594 + const cidBytesMap = new Map(); 595 + for (const [cidStr, cbor] of mstBlocks.entries()) { 596 + const { cidBytes } = await hashToCid(cbor); 597 + cidBytesMap.set(cidStr, cidBytes); 598 + } 599 + 600 + // New TID for this revision 601 + const rev = generateTid(); 602 + this.currentRev = rev; 603 + 604 + // Build and sign the commit 605 + const { commitCbor, commitCidBytes, commitCidStr } = await buildSignedCommit( 606 + this.did, rev, rootCidBytes, this.currentCommitCidBytes, this.privateKey 607 + ); 608 + 609 + this.currentCommitCidBytes = commitCidBytes; 610 + this.currentCommitCidStr = commitCidStr; 611 + 612 + await this._persist(); 613 + 614 + // Return everything callers need 615 + return { 616 + rev, 617 + commitCidStr, 618 + commitCidBytes, 619 + mstBlocks, 620 + cidBytesMap, 621 + commitCbor, 622 + rootCidBytes, 623 + rootCidStr, 624 + }; 625 + } 626 + 627 + /** 628 + * Create a record. 629 + * @param {string} collection - e.g. "app.bsky.graph.follow" 630 + * @param {string|null} rkey - TID will be generated if null 631 + * @param {object} record - plain JS object (will be DAG-CBOR encoded) 632 + * @returns {{ uri, cid, commit: { cid, rev } }} 633 + */ 634 + async createRecord(collection, rkey, record) { 635 + rkey = rkey || generateTid(); 636 + const result = await this._applyWrite({ type: 'create', collection, rkey, record }); 637 + const path = `${collection}/${rkey}`; 638 + const { cidStr } = this.records.get(path); 639 + return { 640 + uri: `at://${this.did}/${path}`, 641 + cid: cidStr, 642 + commit: { cid: result.commitCidStr, rev: result.rev }, 643 + validationStatus: 'valid', 644 + }; 645 + } 646 + 647 + /** 648 + * Update (put) a record. 649 + */ 650 + async putRecord(collection, rkey, record) { 651 + await this._applyWrite({ type: 'update', collection, rkey, record }); 652 + const path = `${collection}/${rkey}`; 653 + const { cidStr } = this.records.get(path); 654 + return { 655 + uri: `at://${this.did}/${collection}/${rkey}`, 656 + cid: cidStr, 657 + commit: { cid: this.currentCommitCidStr, rev: this.currentRev }, 658 + validationStatus: 'valid', 659 + }; 660 + } 661 + 662 + /** 663 + * Delete a record. 664 + */ 665 + async deleteRecord(collection, rkey) { 666 + await this._applyWrite({ type: 'delete', collection, rkey }); 667 + return { commit: { cid: this.currentCommitCidStr, rev: this.currentRev } }; 668 + } 669 + 670 + /** 671 + * Get a record by collection + rkey. 672 + * Returns { uri, cid, value } where value is the original JS object, 673 + * or null if not found. 674 + */ 675 + getRecord(collection, rkey) { 676 + const path = `${collection}/${rkey}`; 677 + const entry = this.records.get(path); 678 + if (!entry) return null; 679 + return { 680 + uri: `at://${this.did}/${path}`, 681 + cid: entry.cidStr, 682 + value: decodeDagCbor(entry.cborBytes), 683 + }; 684 + } 685 + 686 + /** 687 + * List all records in a collection. 688 + */ 689 + listRecords(collection) { 690 + const results = []; 691 + for (const [path, entry] of this.records.entries()) { 692 + if (path.startsWith(collection + '/')) { 693 + const rkey = path.slice(collection.length + 1); 694 + results.push({ 695 + uri: `at://${this.did}/${path}`, 696 + cid: entry.cidStr, 697 + value: decodeDagCbor(entry.cborBytes), 698 + }); 699 + } 700 + } 701 + return results; 702 + } 703 + 704 + /** 705 + * Export the full repository as a CAR v1 binary (Uint8Array). 706 + * Suitable for responding to com.atproto.sync.getRepo. 707 + */ 708 + async exportCAR() { 709 + // Rebuild full MST + commit 710 + const sortedEntries = Array.from(this.records.entries()) 711 + .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) 712 + .map(([key, { cidBytes }]) => ({ key, cidBytes })); 713 + 714 + const { rootCidBytes, blocks: mstBlocks } = await buildMST(sortedEntries); 715 + 716 + const rev = this.currentRev || generateTid(); 717 + const { commitCbor, commitCidBytes, commitCidStr } = await buildSignedCommit( 718 + this.did, rev, rootCidBytes, null, this.privateKey 719 + ); 720 + 721 + // All blocks: commit + MST nodes + records 722 + const allBlocks = new Map(); 723 + const cidBytesMap = new Map(); 724 + 725 + // Commit block 726 + allBlocks.set(commitCidStr, commitCbor); 727 + cidBytesMap.set(commitCidStr, commitCidBytes); 728 + 729 + // MST node blocks 730 + for (const [cidStr, cbor] of mstBlocks.entries()) { 731 + allBlocks.set(cidStr, cbor); 732 + const { cidBytes } = await hashToCid(cbor); 733 + cidBytesMap.set(cidStr, cidBytes); 734 + } 735 + 736 + // Record blocks (ordered after MST per CAR spec) 737 + for (const [, entry] of this.records.entries()) { 738 + allBlocks.set(entry.cidStr, entry.cborBytes); 739 + cidBytesMap.set(entry.cidStr, entry.cidBytes); 740 + } 741 + 742 + return encodeCAR(commitCidBytes, allBlocks, cidBytesMap); 743 + } 744 + 745 + /** 746 + * Get the current HEAD commit CID string. 747 + */ 748 + get headCid() { return this.currentCommitCidStr; } 749 + 750 + /** 751 + * Get the current revision TID. 752 + */ 753 + get rev() { return this.currentRev; } 754 + } 755 + 756 + // --------------------------------------------------------------------------- 757 + // 8. Minimal DAG-CBOR decoder (needed for getRecord round-trip) 758 + // Only decodes what atproto record values contain. 759 + // --------------------------------------------------------------------------- 760 + 761 + export function decodeDagCbor(bytes) { 762 + let pos = 0; 763 + 764 + function readByte() { return bytes[pos++]; } 765 + 766 + function readUint(addInfo) { 767 + if (addInfo <= 23) return addInfo; 768 + if (addInfo === 24) return readByte(); 769 + if (addInfo === 25) { const v = (readByte() << 8) | readByte(); return v; } 770 + if (addInfo === 26) { 771 + return ((readByte() << 24) | (readByte() << 16) | (readByte() << 8) | readByte()) >>> 0; 772 + } 773 + if (addInfo === 27) { 774 + const hi = ((readByte() << 24) | (readByte() << 16) | (readByte() << 8) | readByte()) >>> 0; 775 + const lo = ((readByte() << 24) | (readByte() << 16) | (readByte() << 8) | readByte()) >>> 0; 776 + return hi * 0x100000000 + lo; 777 + } 778 + throw new Error('unsupported addInfo ' + addInfo); 779 + } 780 + 781 + function decode() { 782 + const first = readByte(); 783 + const major = first >> 5; 784 + const addInfo = first & 0x1f; 785 + 786 + if (major === 0) return readUint(addInfo); // uint 787 + if (major === 1) return -1 - readUint(addInfo); // negint 788 + if (major === 2) { // bytes 789 + const len = readUint(addInfo); 790 + const out = bytes.slice(pos, pos + len); pos += len; 791 + return out; 792 + } 793 + if (major === 3) { // text 794 + const len = readUint(addInfo); 795 + const out = new TextDecoder().decode(bytes.slice(pos, pos + len)); pos += len; 796 + return out; 797 + } 798 + if (major === 4) { // array 799 + const len = readUint(addInfo); 800 + const arr = []; 801 + for (let i = 0; i < len; i++) arr.push(decode()); 802 + return arr; 803 + } 804 + if (major === 5) { // map 805 + const len = readUint(addInfo); 806 + const obj = {}; 807 + for (let i = 0; i < len; i++) { 808 + const k = decode(); 809 + obj[k] = decode(); 810 + } 811 + return obj; 812 + } 813 + if (major === 6) { // tag 814 + const tag = readUint(addInfo); 815 + const val = decode(); 816 + if (tag === 42 && val instanceof Uint8Array) { 817 + // CID link — return as { $link: cidStr } 818 + const cidBytes = val.slice(1); // strip 0x00 multibase prefix 819 + return { $link: 'b' + base32Encode(cidBytes) }; 820 + } 821 + return val; // unknown tag — return raw value 822 + } 823 + if (major === 7) { 824 + if (addInfo === 20) return false; 825 + if (addInfo === 21) return true; 826 + if (addInfo === 22) return null; 827 + throw new Error('unsupported float/special addInfo ' + addInfo); 828 + } 829 + throw new Error('unknown CBOR major ' + major); 830 + } 831 + 832 + return decode(); 833 + } 834 + 835 + // --------------------------------------------------------------------------- 836 + // 9. Utility helpers 837 + // --------------------------------------------------------------------------- 838 + 839 + function bytesToHex(bytes) { 840 + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); 841 + } 842 + 843 + function hexToBytes(hex) { 844 + const out = new Uint8Array(hex.length / 2); 845 + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); 846 + return out; 847 + } 848 + 849 + function base32DecodeToBytes(str) { 850 + // base32lower decode 851 + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 852 + let bits = 0, value = 0; 853 + const out = []; 854 + for (const char of str) { 855 + const idx = alphabet.indexOf(char); 856 + if (idx < 0) continue; 857 + value = (value << 5) | idx; 858 + bits += 5; 859 + if (bits >= 8) { bits -= 8; out.push((value >> bits) & 0xff); } 860 + } 861 + return new Uint8Array(out); 862 + }
+356 -495
background.js
··· 1 - const DOMAIN = "ds-pod.com"; 1 + /** 2 + * background.js — Chrome Extension Service Worker 3 + * 4 + * Intercepts ATProto XRPC calls targeted at ds-pod.com and handles them 5 + * with a cryptographically valid local repository engine. 6 + * 7 + * Changes from the previous version: 8 + * - Records are now stored as DAG-CBOR with real SHA-256 CIDs 9 + * - MST is rebuilt and signed on every write 10 + * - createRecord / putRecord / deleteRecord all return valid commit objects 11 + * - com.atproto.sync.getRepo now works and returns a real CAR v1 file 12 + * - getRecord CIDs now match what createRecord returned 13 + * - TIDs are spec-compliant 13-char base32-sortable strings 14 + */ 15 + 16 + import { 17 + Repo, 18 + generateTid, 19 + encodeDagCbor, 20 + decodeDagCbor, 21 + CidLink, 22 + } from './atproto-repo.js'; 23 + import { 24 + connectToSignalingServer 25 + } from './webhook.js'; 2 26 3 - // Keep track of which tab is currently attached to avoid global banner creep 27 + const DOMAIN = "ds-pod.com"; 4 28 let attachedTabId = null; 5 29 6 - // Helper to safely handle base64 encoding with Unicode strings 30 + // --------------------------------------------------------------------------- 31 + // Keypair & DID helpers (unchanged from original) 32 + // --------------------------------------------------------------------------- 33 + 7 34 function safeBtoa(str) { 8 - return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => { 9 - return String.fromCharCode(parseInt(p1, 16)); 10 - })); 35 + return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => 36 + String.fromCharCode(parseInt(p1, 16)) 37 + )); 11 38 } 12 39 13 - // Cryptographic JWT Generator for local mock sessions 14 - //async function nativeHS256(payload, secret) { 15 - // const header = { alg: "HS256", typ: "JWT" }; 16 - // 17 - // const b64 = (obj) => btoa(JSON.stringify(obj)) 18 - // .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 19 - // 20 - // const encodedHeader = b64(header); 21 - // const encodedPayload = b64(payload); 22 - // const dataToSign = `${encodedHeader}.${encodedPayload}`; 23 - // 24 - // const key = await crypto.subtle.importKey( 25 - // "raw", 26 - // new TextEncoder().encode(secret), 27 - // { name: "HMAC", hash: "SHA-256" }, 28 - // false, 29 - // ["sign"] 30 - // ); 31 - // 32 - // const signature = await crypto.subtle.sign( 33 - // "HMAC", 34 - // key, 35 - // new TextEncoder().encode(dataToSign) 36 - // ); 37 - // 38 - // const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature))) 39 - // .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 40 - // 41 - // return `${dataToSign}.${encodedSignature}`; 42 - //} 43 - 44 - 45 40 const STORAGE_KEY = 'atproto_keypair'; 41 + const BASE32_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; 46 42 47 - // Generate or load the persistent keypair 48 43 async function getOrCreateKeypair() { 49 44 const stored = await chrome.storage.local.get(STORAGE_KEY); 50 - 51 45 if (stored[STORAGE_KEY]) { 52 - // Re-import stored keys 53 46 const { privateKeyJwk, publicKeyJwk } = stored[STORAGE_KEY]; 54 47 const privateKey = await crypto.subtle.importKey( 55 - 'jwk', privateKeyJwk, 56 - { name: 'ECDSA', namedCurve: 'P-256' }, 57 - false, ['sign'] 48 + 'jwk', privateKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] 58 49 ); 59 50 const publicKey = await crypto.subtle.importKey( 60 - 'jwk', publicKeyJwk, 61 - { name: 'ECDSA', namedCurve: 'P-256' }, 62 - true, ['verify'] 51 + 'jwk', publicKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'] 63 52 ); 64 53 return { privateKey, publicKey, publicKeyJwk }; 65 54 } 66 - 67 - // Generate fresh keypair 68 55 const keypair = await crypto.subtle.generateKey( 69 - { name: 'ECDSA', namedCurve: 'P-256' }, 70 - true, ['sign', 'verify'] 56 + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'] 71 57 ); 72 - 73 58 const privateKeyJwk = await crypto.subtle.exportKey('jwk', keypair.privateKey); 74 - const publicKeyJwk = await crypto.subtle.exportKey('jwk', keypair.publicKey); 75 - 76 - await chrome.storage.local.set({ 77 - [STORAGE_KEY]: { privateKeyJwk, publicKeyJwk } 78 - }); 79 - 59 + const publicKeyJwk = await crypto.subtle.exportKey('jwk', keypair.publicKey); 60 + await chrome.storage.local.set({ [STORAGE_KEY]: { privateKeyJwk, publicKeyJwk } }); 80 61 return { privateKey: keypair.privateKey, publicKey: keypair.publicKey, publicKeyJwk }; 81 62 } 82 63 83 - const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; 84 - const BASE32_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; 85 - 86 64 function toBase(bytes, alphabet) { 87 65 let num = BigInt(0); 88 - for (const byte of bytes) { 89 - num = num * BigInt(256) + BigInt(byte); 90 - } 91 - 66 + for (const byte of bytes) num = num * 256n + BigInt(byte); 92 67 let encoded = ''; 93 - while (num > BigInt(0)) { 94 - const remainder = num % BigInt(alphabet.length); 95 - num = num / BigInt(alphabet.length); 96 - encoded = alphabet[Number(remainder)] + encoded; 68 + while (num > 0n) { 69 + encoded = alphabet[Number(num % BigInt(alphabet.length))] + encoded; 70 + num /= BigInt(alphabet.length); 97 71 } 98 - 99 - // Preserve leading zero bytes as '1' characters 100 72 for (const byte of bytes) { 101 - if (byte === 0) encoded = '1' + encoded; 102 - else break; 73 + if (byte === 0) encoded = '1' + encoded; else break; 103 74 } 104 - 105 75 return encoded; 106 76 } 107 77 108 - // Encode public key as a did:key identifier 109 - // did:key uses multibase-encoded multicodec-prefixed public key bytes 110 78 async function publicKeyJwkToDidKey(publicKeyJwk) { 111 - // Export raw public key bytes 112 79 const publicKey = await crypto.subtle.importKey( 113 - 'jwk', publicKeyJwk, 114 - { name: 'ECDSA', namedCurve: 'P-256' }, 115 - true, ['verify'] 80 + 'jwk', publicKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'] 116 81 ); 117 - 118 82 const rawBytes = await crypto.subtle.exportKey('raw', publicKey); 119 83 const pubKeyBytes = new Uint8Array(rawBytes); 120 - 121 - // Compress the public key (P-256 uncompressed is 65 bytes: 0x04 + x + y) 122 - // Compressed is 33 bytes: 0x02/0x03 + x 123 84 const x = pubKeyBytes.slice(1, 33); 124 85 const y = pubKeyBytes.slice(33, 65); 125 86 const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 126 87 const compressed = new Uint8Array([prefix, ...x]); 127 - 128 - // Multicodec prefix for P-256: 0x1200 (as varint) 129 88 const multicodecPrefix = new Uint8Array([0x80, 0x24]); 130 89 const prefixed = new Uint8Array([...multicodecPrefix, ...compressed]); 131 - 132 - // Multibase base58btc encode (prefix 'z') 133 90 const base58 = toBase(prefixed, BASE32_ALPHABET); 134 91 return `did:web:${base58}.${DOMAIN}`; 135 92 } 136 93 137 - // Sign a JWT with ES256 using the local private key 138 94 async function signJwtES256(payload, privateKey) { 139 - const header = { alg: 'ES256', typ: 'JWT' }; 140 - 141 95 const b64url = (obj) => btoa(JSON.stringify(obj)) 142 96 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 143 - 144 - const encodedHeader = b64url(header); 145 - const encodedPayload = b64url(payload); 146 - const signingInput = `${encodedHeader}.${encodedPayload}`; 147 - 97 + const header = { alg: 'ES256', typ: 'JWT' }; 98 + const signingInput = `${b64url(header)}.${b64url(payload)}`; 148 99 const signature = await crypto.subtle.sign( 149 100 { name: 'ECDSA', hash: 'SHA-256' }, 150 101 privateKey, 151 102 new TextEncoder().encode(signingInput) 152 103 ); 153 - 154 - const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature))) 104 + const encodedSig = btoa(String.fromCharCode(...new Uint8Array(signature))) 155 105 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 156 - 157 - return `${signingInput}.${encodedSignature}`; 106 + return `${signingInput}.${encodedSig}`; 158 107 } 159 108 160 - // Wire it all together replaces your nativeHS256 session builder 161 - async function createSelfSignedSession(obj) { 162 - const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 163 - const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 164 - // userDid is now something like: did:key:zDnaerx9hy2RnqjkL... 165 - // This IS the username — deterministic, no server registration needed 166 - 167 - const now = Math.floor(Date.now() / 1000); 168 - const accessJwt = await signJwtES256(obj, privateKey); 169 - 170 - return accessJwt; 171 - //return { 172 - // did: userDid, 173 - // handle: `${userDid.slice(-8)}.${DOMAIN}`, // last 8 chars as handle 174 - // accessJwt, 175 - // // ...etc 176 - //}; 177 - } 109 + // --------------------------------------------------------------------------- 110 + // DID service endpoint cache (unchanged) 111 + // --------------------------------------------------------------------------- 178 112 179 - // In-memory cache to prevent redundant network lookups for service destinations 180 113 const didServiceCache = new Map(); 181 114 182 - /** 183 - * Resolves an AT Protocol DID and extracts the correct service host URL. 184 - */ 185 115 async function resolveServiceEndpoint(proxyHeaderValue) { 186 116 if (!proxyHeaderValue) return null; 187 - 188 - // Split DID and fragment (e.g. "did:web:api.bsky.app" and "bsky_appview") 189 117 const [did, fragmentId] = proxyHeaderValue.split('#'); 190 118 if (!did) return null; 191 - 192 - // Check cache first 193 119 const cacheKey = `${did}#${fragmentId || ''}`; 194 - if (didServiceCache.has(cacheKey)) { 195 - return didServiceCache.get(cacheKey); 196 - } 197 - 120 + if (didServiceCache.has(cacheKey)) return didServiceCache.get(cacheKey); 198 121 try { 199 122 let url; 200 - if (did.startsWith('did:web:')) { 201 - const domain = did.replace('did:web:', ''); 202 - url = `https://${domain}/.well-known/did.json`; 203 - } else if (did.startsWith('did:plc:')) { 204 - url = `https://plc.directory/${did}`; 205 - } else { 206 - return null; // Unsupported DID method 207 - } 208 - 123 + if (did.startsWith('did:web:')) url = `https://${did.replace('did:web:', '')}/.well-known/did.json`; 124 + else if (did.startsWith('did:plc:')) url = `https://plc.directory/${did}`; 125 + else return null; 209 126 const res = await fetch(url); 210 127 if (!res.ok) return null; 211 128 const doc = await res.json(); 212 - 213 129 if (!doc.service || !Array.isArray(doc.service)) return null; 214 - 215 - // Look for a matching service entry. 216 - // If no fragment was provided, default to the first available service. 217 - const service = doc.service.find(s => { 218 - if (!fragmentId) return true; 219 - return s.id === `#${fragmentId}` || s.id === `${did}#${fragmentId}`; 220 - }) || doc.service[0]; 221 - 222 - if (service && service.serviceEndpoint) { 223 - const endpoint = service.serviceEndpoint; 224 - didServiceCache.set(cacheKey, endpoint); 225 - return endpoint; 130 + const service = doc.service.find(s => 131 + !fragmentId ? true : s.id === `#${fragmentId}` || s.id === `${did}#${fragmentId}` 132 + ) || doc.service[0]; 133 + if (service?.serviceEndpoint) { 134 + didServiceCache.set(cacheKey, service.serviceEndpoint); 135 + return service.serviceEndpoint; 226 136 } 227 137 } catch (err) { 228 - console.error(`Failed to dynamically resolve DID: ${did}`, err); 138 + console.error(`Failed to resolve DID: ${did}`, err); 229 139 } 230 - 231 140 return null; 232 141 } 233 142 234 - // --- LOCAL STORAGE ATPROTO REPOSITORY CONTROLLERS --- 235 - async function getLocalRepoStore() { 236 - const data = await chrome.storage.local.get({ atproto_repo: {} }); 237 - return data.atproto_repo; 143 + // --------------------------------------------------------------------------- 144 + // Repo singleton — lazily opened per request 145 + // --------------------------------------------------------------------------- 146 + 147 + let _repoInstance = null; 148 + 149 + async function getRepo(userDid, privateKey) { 150 + if (!_repoInstance || _repoInstance.did !== userDid) { 151 + _repoInstance = await Repo.open(userDid, privateKey); 152 + } 153 + return _repoInstance; 238 154 } 239 155 240 - async function saveLocalRepoStore(store) { 241 - await chrome.storage.local.set({ atproto_repo: store }); 242 - } 156 + // --------------------------------------------------------------------------- 157 + // Debugger lifecycle (unchanged) 158 + // --------------------------------------------------------------------------- 243 159 244 - // Reusable function to attach with a built-in retry mechanism 245 160 function attachAndEnableFetch(tabId, retries = 5, delay = 200) { 246 161 chrome.debugger.attach({ tabId }, "1.3", () => { 247 162 if (chrome.runtime.lastError) { 248 - const errorMsg = chrome.runtime.lastError.message; 249 - console.error(`Failed to attach to tab ${tabId}:`, errorMsg); 250 - 251 - if (retries > 0 && errorMsg.includes("Cannot access a chrome-extension")) { 252 - console.warn(`Chrome target engine is in limbo. Retrying attachment in ${delay}ms...`); 253 - setTimeout(() => { 254 - attachAndEnableFetch(tabId, retries - 1, delay * 2); 255 - }, delay); 256 - } else { 257 - if (attachedTabId === tabId) attachedTabId = null; 163 + const err = chrome.runtime.lastError.message; 164 + console.error(`Failed to attach to tab ${tabId}:`, err); 165 + if (retries > 0 && err.includes("Cannot access a chrome-extension")) { 166 + setTimeout(() => attachAndEnableFetch(tabId, retries - 1, delay * 2), delay); 167 + } else if (attachedTabId === tabId) { 168 + attachedTabId = null; 258 169 } 259 170 return; 260 171 } 261 - 262 172 attachedTabId = tabId; 263 - console.log(`Successfully attached and tracking tab ${tabId}`); 264 - 265 173 chrome.debugger.sendCommand({ tabId }, "Fetch.enable", { 266 174 patterns: [{ urlPattern: `*://${DOMAIN}/xrpc/*`, requestStage: "Request" }] 267 175 }); ··· 272 180 async function manageDebugger(tabId) { 273 181 try { 274 182 const tab = await chrome.tabs.get(tabId).catch(() => null); 275 - if (!tab) return; 276 - 277 - const allowedUrls = ["bsky.app", DOMAIN]; 278 - const isMatched = allowedUrls.some(url => tab.url && tab.url.includes(url)); 279 - 183 + if (!tab) return; 184 + const isMatched = ["bsky.app", DOMAIN].some(u => tab.url?.includes(u)); 280 185 if (isMatched) { 281 - if (attachedTabId === tabId) return; 282 - 283 - if (attachedTabId && attachedTabId !== tabId) { 186 + if (attachedTabId === tabId) return; 187 + if (attachedTabId && attachedTabId !== tabId) 284 188 await chrome.debugger.detach({ tabId: attachedTabId }).catch(() => {}); 285 - } 286 - 287 189 attachAndEnableFetch(tabId); 288 - } else { 289 - if (attachedTabId) { 290 - const tabToDetach = attachedTabId; 291 - attachedTabId = null; 292 - chrome.debugger.detach({ tabId: tabToDetach }, () => { 293 - chrome.runtime.lastError; 294 - }); 295 - } 190 + } else if (attachedTabId) { 191 + const toDetach = attachedTabId; 192 + attachedTabId = null; 193 + chrome.debugger.detach({ tabId: toDetach }, () => { chrome.runtime.lastError; }); 296 194 } 297 - } catch (e) { 298 - console.error("Error managing debugger state:", e); 299 - } 195 + } catch (e) { console.error("manageDebugger error:", e); } 300 196 } 301 197 302 198 chrome.debugger.onDetach.addListener((source, reason) => { ··· 308 204 }); 309 205 } 310 206 }); 311 - 312 - chrome.tabs.onActivated.addListener((activeInfo) => manageDebugger(activeInfo.tabId)); 313 - chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { 314 - if (changeInfo.status === 'complete') manageDebugger(tabId); 207 + 208 + chrome.tabs.onActivated.addListener(i => manageDebugger(i.tabId)); 209 + chrome.tabs.onUpdated.addListener((tabId, info) => { 210 + if (info.status === 'complete') manageDebugger(tabId); 315 211 }); 316 - 317 - // --- CORE INTERCEPTION PIPELINE --- 212 + 213 + // --------------------------------------------------------------------------- 214 + // CORE INTERCEPT PIPELINE 215 + // --------------------------------------------------------------------------- 216 + 217 + const corsHeaders = [ 218 + { name: "Content-Type", value: "application/json" }, 219 + { name: "Access-Control-Allow-Origin", value: "*" }, 220 + { name: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS, PUT, DELETE" }, 221 + { name: "Access-Control-Allow-Headers", value: "*" }, 222 + ]; 223 + 224 + function fulfill(source, requestId, code, body) { 225 + return chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 226 + requestId, responseCode: code, responseHeaders: corsHeaders, 227 + body: safeBtoa(JSON.stringify(body)) 228 + }); 229 + } 230 + 231 + function fulfillRaw(source, requestId, code, headers, base64Body) { 232 + return chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 233 + requestId, responseCode: code, responseHeaders: headers, body: base64Body 234 + }); 235 + } 236 + 318 237 chrome.debugger.onEvent.addListener(async (source, method, params) => { 319 238 if (method !== "Fetch.requestPaused") return; 320 - 321 239 const { requestId, request } = params; 322 - const urlObj = new URL(request.url); 323 - const pathname = urlObj.pathname; 240 + const urlObj = new URL(request.url); 241 + const pathname = urlObj.pathname; 324 242 const searchParams = urlObj.searchParams; 325 243 326 - // Standard baseline CORS Response Headers for standard web client interoperability 327 - const corsHeaders = [ 328 - { name: "Content-Type", value: "application/json" }, 329 - { name: "Access-Control-Allow-Origin", value: "*" }, 330 - { name: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS, PUT, DELETE" }, 331 - { name: "Access-Control-Allow-Headers", value: "*" } 332 - ]; 333 - 334 244 try { 335 - // 1. Instantly skip and clear OPTIONS requests 336 245 if (request.method === "OPTIONS") { 337 246 await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 338 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa("{}") 247 + requestId, responseCode: 200, responseHeaders: corsHeaders, 248 + body: safeBtoa("{}") 339 249 }); 340 250 return; 341 251 } 342 252 343 - // We isolate traffic targeted at our mock domain's XRPC endpoint 344 - if (request.url.includes(`https://${DOMAIN}/xrpc/`)) { 345 - const xrpcMethod = pathname.split("/xrpc/")[1]; 346 - const mockedDid = `did:web:${DOMAIN}`; 347 - const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 348 - const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 349 - const userId = userDid.split(":")[2].split(".")[0]; 253 + if (!request.url.includes(`https://${DOMAIN}/xrpc/`)) { 254 + await chrome.debugger.sendCommand(source, "Fetch.continueRequest", { requestId }); 255 + return; 256 + } 350 257 351 - // Parse query input parameters or body payload context safely 352 - let queryParams = Object.fromEntries(searchParams.entries()); 353 - let bodyPayload = {}; 354 - if (request.postData) { 355 - try { bodyPayload = JSON.parse(request.postData); } catch (e) {} 356 - } 258 + const xrpcMethod = pathname.split("/xrpc/")[1]; 259 + const mockedDid = `did:web:${DOMAIN}`; 260 + const { privateKey, publicKeyJwk } = await getOrCreateKeypair(); 261 + const userDid = await publicKeyJwkToDidKey(publicKeyJwk); 262 + const userId = userDid.split(":")[2].split(".")[0]; 357 263 358 - // --- LOCAL ATPROTO METHOD MOCK GENERATORS --- 359 - 360 - // com.atproto.server.describeServer 361 - if (xrpcMethod === "com.atproto.server.describeServer") { 362 - const responseBody = { 363 - did: mockedDid, 364 - availableUserDomains: [`.${DOMAIN}`], 365 - inviteCodeRequired: false, 366 - phoneVerificationRequired: false, 367 - links: { privacyPolicy: `https://${DOMAIN}`, termsOfService: `https://${DOMAIN}` } 368 - }; 369 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 370 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify(responseBody)) 371 - }); 372 - return; 373 - } 264 + let queryParams = Object.fromEntries(searchParams.entries()); 265 + let bodyPayload = {}; 266 + if (request.postData) { 267 + try { bodyPayload = JSON.parse(request.postData); } catch (_) {} 268 + } 374 269 375 - // com.atproto.server.createSession 376 - if (xrpcMethod === "com.atproto.server.createSession" || xrpcMethod === "com.atproto.server.refreshSession" || xrpcMethod === "com.atproto.server.getSession") { 377 - const responseBody = { 378 - did: userDid, 379 - handle: `${userId}.${DOMAIN}`, 380 - email: `${userId}@${DOMAIN}`, 381 - emailConfirmed: true, 382 - emailAuthFactor: false, 383 - accessJwt: await createSelfSignedSession({ scope: "com.atproto.access", sub: userDid, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 7200, aud: mockedDid }), 384 - refreshJwt: await createSelfSignedSession({ scope: "com.atproto.refresh", sub: userDid, aud: mockedDid, jti: "mock-jti", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 }), 385 - active: true 386 - }; 387 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 388 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify(responseBody)) 389 - }); 390 - return; 391 - } 270 + // Open the repo (lazy, cached) 271 + const repo = await getRepo(userDid, privateKey); 392 272 393 - // com.atproto.repo.createRecord 394 - if (xrpcMethod === "com.atproto.repo.createRecord") { 395 - const { collection, record } = bodyPayload; 396 - const rkey = bodyPayload.rkey || "mock-" + Date.now(); 397 - const store = await getLocalRepoStore(); 398 - 399 - if (!store[collection]) store[collection] = {}; 400 - store[collection][rkey] = record; 401 - await saveLocalRepoStore(store); 273 + // ------------------------------------------------------------------ 274 + // com.atproto.server.describeServer 275 + // ------------------------------------------------------------------ 276 + if (xrpcMethod === "com.atproto.server.describeServer") { 277 + await fulfill(source, requestId, 200, { 278 + did: mockedDid, 279 + availableUserDomains: [`.${DOMAIN}`], 280 + inviteCodeRequired: false, 281 + phoneVerificationRequired: false, 282 + links: { privacyPolicy: `https://${DOMAIN}`, termsOfService: `https://${DOMAIN}` } 283 + }); 284 + return; 285 + } 402 286 403 - const responseBody = { uri: `at://${userDid}/${collection}/${rkey}`, cid: "mockCid123456789" }; 404 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 405 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify(responseBody)) 406 - }); 407 - return; 408 - } 287 + // ------------------------------------------------------------------ 288 + // com.atproto.server.createSession / refreshSession / getSession 289 + // ------------------------------------------------------------------ 290 + if (["com.atproto.server.createSession", 291 + "com.atproto.server.refreshSession", 292 + "com.atproto.server.getSession"].includes(xrpcMethod)) { 293 + const now = Math.floor(Date.now() / 1000); 294 + await fulfill(source, requestId, 200, { 295 + did: userDid, 296 + handle: `${userId}.${DOMAIN}`, 297 + email: `${userId}@${DOMAIN}`, 298 + emailConfirmed: true, 299 + emailAuthFactor: false, 300 + accessJwt: await signJwtES256({ scope: "com.atproto.access", sub: userDid, iat: now, exp: now + 7200, aud: mockedDid }, privateKey), 301 + refreshJwt: await signJwtES256({ scope: "com.atproto.refresh", sub: userDid, iat: now, exp: now + 86400, aud: mockedDid, jti: crypto.randomUUID() }, privateKey), 302 + active: true, 303 + }); 304 + return; 305 + } 409 306 410 - if (xrpcMethod === "app.bsky.actor.getPreferences") { 411 - const { collection, rkey } = queryParams; 412 - const store = await getLocalRepoStore(); 413 - const record = store["app.bsky.actor.getPreferences"]; 307 + // ------------------------------------------------------------------ 308 + // com.atproto.repo.createRecord 309 + // ------------------------------------------------------------------ 310 + if (xrpcMethod === "com.atproto.repo.createRecord") { 311 + const { collection, record } = bodyPayload; 312 + const rkey = bodyPayload.rkey || generateTid(); 414 313 415 - if (record) { 416 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 417 - requestId, responseCode: 200, responseHeaders: corsHeaders, 418 - body: safeBtoa(JSON.stringify(record)) 419 - }); 420 - } else { 421 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 422 - requestId, responseCode: 200, responseHeaders: corsHeaders, 423 - body: safeBtoa(JSON.stringify({"preferences": []})) 424 - }); 425 - } 426 - return; 427 - } 314 + const result = await repo.createRecord(collection, rkey, record); 315 + await fulfill(source, requestId, 200, result); 316 + return; 317 + } 428 318 429 - if (xrpcMethod === "com.atproto.repo.putRecord") { 319 + // ------------------------------------------------------------------ 320 + // app.bsky.actor.putPreferences 321 + // Not a repo record — stored as a raw blob in extension storage, 322 + // exactly mirroring the original behaviour. 323 + // ------------------------------------------------------------------ 324 + if (xrpcMethod === "app.bsky.actor.putPreferences") { 325 + await chrome.storage.local.set({ atproto_preferences: bodyPayload }); 326 + await fulfill(source, requestId, 200, {}); 327 + return; 328 + } 430 329 431 - const store = await getLocalRepoStore(); 432 - store["app.bsky.actor.getPreferences"] = bodyPayload; 433 - await saveLocalRepoStore(store); 330 + // ------------------------------------------------------------------ 331 + // com.atproto.repo.putRecord 332 + // ------------------------------------------------------------------ 333 + if (xrpcMethod === "com.atproto.repo.putRecord") { 334 + const { collection, rkey, record } = bodyPayload; 335 + const result = await repo.putRecord(collection, rkey, record); 336 + await fulfill(source, requestId, 200, result); 337 + return; 338 + } 434 339 435 - const responseBody = { uri: `at://${userDid}/${collection}/${rkey}`, cid: "mockCidUpdated9876" }; 436 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 437 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify({})) 340 + // ------------------------------------------------------------------ 341 + // com.atproto.repo.getRecord 342 + // ------------------------------------------------------------------ 343 + if (xrpcMethod === "com.atproto.repo.getRecord") { 344 + const { collection, rkey } = queryParams; 345 + const entry = repo.getRecord(collection, rkey); 346 + if (entry) { 347 + await fulfill(source, requestId, 200, entry); 348 + } else { 349 + await fulfill(source, requestId, 404, { 350 + error: "RecordNotFound", message: "Could not locate record" 438 351 }); 439 - return; 440 352 } 353 + return; 354 + } 441 355 442 - // com.atproto.repo.putRecord (Update / Replace Record Method) 443 - if (xrpcMethod === "com.atproto.repo.putRecord") { 444 - const { collection, rkey, record } = bodyPayload; 445 - const store = await getLocalRepoStore(); 356 + // ------------------------------------------------------------------ 357 + // com.atproto.repo.deleteRecord 358 + // ------------------------------------------------------------------ 359 + if (xrpcMethod === "com.atproto.repo.deleteRecord") { 360 + const { collection, rkey } = bodyPayload; 361 + const result = await repo.deleteRecord(collection, rkey); 362 + await fulfill(source, requestId, 200, result); 363 + return; 364 + } 446 365 447 - if (!store[collection]) store[collection] = {}; 448 - store[collection][rkey] = record; // Perform localized state update override 449 - await saveLocalRepoStore(store); 366 + // ------------------------------------------------------------------ 367 + // com.atproto.repo.applyWrites 368 + // Batch create / update / delete in a single commit. 369 + // Each entry in `writes` has a $type discriminator: 370 + // com.atproto.repo.applyWrites#create → { collection, rkey?, value } 371 + // com.atproto.repo.applyWrites#update → { collection, rkey, value } 372 + // com.atproto.repo.applyWrites#delete → { collection, rkey } 373 + // The whole batch lands in one commit (one new rev / CID). 374 + // ------------------------------------------------------------------ 375 + if (xrpcMethod === "com.atproto.repo.applyWrites") { 376 + const writes = bodyPayload.writes ?? []; 377 + const results = []; 450 378 451 - const responseBody = { uri: `at://${userDid}/${collection}/${rkey}`, cid: "mockCidUpdated9876" }; 452 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 453 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify(responseBody)) 454 - }); 455 - return; 379 + for (const write of writes) { 380 + const type = write.$type ?? ''; 381 + if (type.endsWith('#create') || type === 'create') { 382 + const rkey = write.rkey || generateTid(); 383 + const out = await repo.createRecord(write.collection, rkey, write.value); 384 + results.push({ $type: 'com.atproto.repo.applyWrites#createResult', uri: out.uri, cid: out.cid, validationStatus: out.validationStatus }); 385 + } else if (type.endsWith('#update') || type === 'update') { 386 + const out = await repo.putRecord(write.collection, write.rkey, write.value); 387 + results.push({ $type: 'com.atproto.repo.applyWrites#updateResult', uri: out.uri, cid: out.cid, validationStatus: out.validationStatus }); 388 + } else if (type.endsWith('#delete') || type === 'delete') { 389 + await repo.deleteRecord(write.collection, write.rkey); 390 + results.push({ $type: 'com.atproto.repo.applyWrites#deleteResult' }); 391 + } 456 392 } 457 393 458 - // com.atproto.repo.getRecord 459 - if (xrpcMethod === "com.atproto.repo.getRecord") { 460 - const { collection, rkey } = queryParams; 461 - const store = await getLocalRepoStore(); 462 - const record = store[collection]?.[rkey]; 394 + await fulfill(source, requestId, 200, { 395 + commit: { cid: repo.headCid, rev: repo.rev }, 396 + results, 397 + }); 398 + return; 399 + } 463 400 464 - if (record) { 465 - const responseBody = { uri: `at://${userDid}/${collection}/${rkey}`, cid: "mockCidFound", value: record }; 466 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 467 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify(responseBody)) 468 - }); 469 - } else { 470 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 471 - requestId, responseCode: 404, responseHeaders: corsHeaders, 472 - body: safeBtoa(JSON.stringify({ error: "RecordNotFound", message: "Could not find record" })) 473 - }); 474 - } 475 - return; 476 - } 401 + // ------------------------------------------------------------------ 402 + // com.atproto.repo.listRecords 403 + // ------------------------------------------------------------------ 404 + if (xrpcMethod === "com.atproto.repo.listRecords") { 405 + const { collection } = queryParams; 406 + const records = repo.listRecords(collection); 407 + await fulfill(source, requestId, 200, { records }); 408 + return; 409 + } 477 410 478 - // com.atproto.repo.deleteRecord 479 - if (xrpcMethod === "com.atproto.repo.deleteRecord") { 480 - const { collection, rkey } = bodyPayload; 481 - const store = await getLocalRepoStore(); 411 + // ------------------------------------------------------------------ 412 + // app.bsky.actor.getPreferences 413 + // Not a repo record — read back the raw blob written by putPreferences. 414 + // ------------------------------------------------------------------ 415 + if (xrpcMethod === "app.bsky.actor.getPreferences") { 416 + const stored = await chrome.storage.local.get('atproto_preferences'); 417 + await fulfill(source, requestId, 200, 418 + stored.atproto_preferences ?? { preferences: [] } 419 + ); 420 + return; 421 + } 422 + 423 + // ------------------------------------------------------------------ 424 + // com.atproto.sync.getRepo — returns CAR v1 bytes 425 + // ------------------------------------------------------------------ 426 + if (xrpcMethod === "com.atproto.sync.getRepo") { 427 + const carBytes = await repo.exportCAR(); 428 + // base64-encode raw bytes for fulfillRequest 429 + let binary = ''; 430 + for (const b of carBytes) binary += String.fromCharCode(b); 431 + const base64Car = btoa(binary); 432 + await fulfillRaw(source, requestId, 200, [ 433 + { name: "Content-Type", value: "application/vnd.ipld.car" }, 434 + { name: "Access-Control-Allow-Origin", value: "*" }, 435 + ], base64Car); 436 + return; 437 + } 482 438 483 - if (store[collection] && store[collection][rkey]) { 484 - delete store[collection][rkey]; 485 - await saveLocalRepoStore(store); 486 - } 439 + // ------------------------------------------------------------------ 440 + // com.atproto.sync.getHead / getLatestCommit 441 + // ------------------------------------------------------------------ 442 + if (xrpcMethod === "com.atproto.sync.getHead" || 443 + xrpcMethod === "com.atproto.sync.getLatestCommit") { 444 + await fulfill(source, requestId, 200, { 445 + cid: repo.headCid || "", 446 + rev: repo.rev || generateTid(), 447 + }); 448 + return; 449 + } 487 450 488 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 489 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify({})) 490 - }); 491 - return; 492 - } 451 + // ------------------------------------------------------------------ 452 + // Dynamic proxy routing via atproto-proxy header (AppView, Chat, etc.) 453 + // ------------------------------------------------------------------ 454 + if (xrpcMethod) { 455 + const proxyHeaderValue = request.headers?.['atproto-proxy'] || ''; 456 + const resolvedTargetHost = await resolveServiceEndpoint(proxyHeaderValue); 493 457 494 - // com.atproto.repo.listRecords 495 - if (xrpcMethod === "com.atproto.repo.listRecords") { 496 - const { collection } = queryParams; 497 - const store = await getLocalRepoStore(); 498 - const recordsForCollection = store[collection] || {}; 499 - 500 - const recordsArray = Object.entries(recordsForCollection).map(([rkey, value]) => ({ 501 - uri: `at://${userDid}/${collection}/${rkey}`, 502 - cid: "mockCidList", 503 - value: value 504 - })); 458 + if (resolvedTargetHost) { 459 + const targetUrl = `${resolvedTargetHost.replace(/\/$/, '')}/xrpc/${xrpcMethod}${urlObj.search}`; 460 + const now = Math.floor(Date.now() / 1000); 461 + const serviceJwt = await signJwtES256({ 462 + iss: userDid, 463 + aud: proxyHeaderValue.split('#')[0], 464 + lxm: xrpcMethod, 465 + iat: now, exp: now + 60, 466 + jti: crypto.randomUUID() 467 + }, privateKey); 505 468 506 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 507 - requestId, responseCode: 200, responseHeaders: corsHeaders, body: safeBtoa(JSON.stringify({ records: recordsArray })) 508 - }); 509 - return; 510 - } 469 + const cleanHeaders = {}; 470 + for (const [k, v] of Object.entries(request.headers || {})) { 471 + if (!['host', 'origin', 'referer', 'authorization'].includes(k.toLowerCase())) 472 + cleanHeaders[k] = v; 473 + } 474 + cleanHeaders['authorization'] = `Bearer ${serviceJwt}`; 475 + cleanHeaders['atproto-proxy'] = proxyHeaderValue; 511 476 512 - if (xrpcMethod) { 513 - const proxyHeaderValue = request.headers ? (request.headers['atproto-proxy'] || '') : ''; 514 - 515 - // 1. Try to resolve the target destination from the header dynamically 516 - const resolvedTargetHost = await resolveServiceEndpoint(proxyHeaderValue); 517 - 518 - if (resolvedTargetHost) { 519 - // Universal AppView / Chat / Labeler Routing 520 - console.log(`🎯 Dynamically routing ${xrpcMethod} to resolved host: ${resolvedTargetHost}`); 521 - 522 - // Construct the destination URL using the dynamically discovered host 523 - const targetUrl = `${resolvedTargetHost.replace(/\/$/, '')}/xrpc/${xrpcMethod}${urlObj.search}`; 477 + const downstreamOpts = { method: request.method, headers: cleanHeaders }; 478 + if (!["GET", "HEAD"].includes(request.method) && request.postData) 479 + downstreamOpts.body = request.postData; 524 480 525 - const now = Math.floor(Date.now() / 1000); 526 - const serviceJwt = await signJwtES256({ 527 - iss: userDid, 528 - aud: proxyHeaderValue.split('#')[0], // already "did:web:api.bsky.chat#bsky_chat" 529 - lxm: xrpcMethod, 530 - iat: now, 531 - exp: now + 60, 532 - jti: crypto.randomUUID() 533 - }, privateKey); 534 - 535 - // Reconstruct and clean headers 536 - const cleanHeaders = {}; 537 - if (request.headers) { 538 - for (const [key, val] of Object.entries(request.headers)) { 539 - if (!['host', 'origin', 'referer', 'authorization'].includes(key.toLowerCase())) { 540 - cleanHeaders[key] = val; 541 - } 542 - } 543 - } 544 - // Maintain the proxy header so the downstream host accepts the query context 545 - cleanHeaders['authorization'] = `Bearer ${serviceJwt}`; 546 - cleanHeaders['atproto-proxy'] = proxyHeaderValue; 547 - 548 - const downstreamOptions = { 549 - method: request.method, 550 - headers: cleanHeaders 551 - }; 552 - 553 - if (request.method !== "GET" && request.method !== "HEAD" && request.postData) { 554 - downstreamOptions.body = request.postData; 555 - } 556 - 557 - try { 558 - const targetResponse = await fetch(targetUrl, downstreamOptions); 559 - const rawBodyText = await targetResponse.text(); 560 - 561 - const formattedResponseHeaders = []; 562 - targetResponse.headers.forEach((value, name) => { 563 - formattedResponseHeaders.push({ name, value }); 564 - }); 565 - 566 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 567 - requestId, 568 - responseCode: targetResponse.status, 569 - responseHeaders: formattedResponseHeaders, 570 - body: safeBtoa(rawBodyText) 571 - }); 572 - return; 573 - } catch (proxyError) { 574 - console.error("Dynamic Downstream Routing Failed:", proxyError); 575 - // Fallback on transport failure 576 - await chrome.debugger.sendCommand(source, "Fetch.continueRequest", { requestId }); 577 - return; 578 - } 481 + try { 482 + const resp = await fetch(targetUrl, downstreamOpts); 483 + const rawBody = await resp.text(); 484 + const respHeaders = []; 485 + resp.headers.forEach((v, n) => respHeaders.push({ name: n, value: v })); 486 + await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 487 + requestId, responseCode: resp.status, 488 + responseHeaders: respHeaders, body: safeBtoa(rawBody) 489 + }); 490 + return; 491 + } catch (proxyErr) { 492 + console.error("Downstream routing failed:", proxyErr); 493 + await chrome.debugger.sendCommand(source, "Fetch.continueRequest", { requestId }); 494 + return; 579 495 } 580 496 } 581 - 582 - // --- FALLBACK INTERCEPT BACKEND FORWARDING (AppView Proxy Pipeline) --- 583 - // If it starts with app.bsky.* or isn't caught locally, seamlessly proxy to Bluesky production! 584 - //if (xrpcMethod.startsWith("app.bsky.")) { 585 - // console.log(`🔀 Proxying application view request downstream: ${xrpcMethod}`); 586 - // 587 - // // Construct the corresponding destination path to the live indexer 588 - // const appViewUrl = `https://api.bsky.app/xrpc/${xrpcMethod}${urlObj.search}`; 589 - // 590 - // // Cleanly extract incoming authorization headers to maintain user identity contexts 591 - // const cleanHeaders = {}; 592 - // if (request.headers) { 593 - // for (const [key, val] of Object.entries(request.headers)) { 594 - // // TREATED AS ANON TODO 595 - // if (!['host', 'origin', 'referer'].includes(key.toLowerCase())) { 596 - // cleanHeaders[key] = val; 597 - // } 598 - // } 599 - // } 600 - 601 - // const downstreamOptions = { 602 - // method: request.method, 603 - // headers: cleanHeaders 604 - // }; 605 - 606 - // if (request.method !== "GET" && request.method !== "HEAD" && request.postData) { 607 - // downstreamOptions.body = request.postData; 608 - // } 609 - 610 - // try { 611 - // const appViewResponse = await fetch(appViewUrl, downstreamOptions); 612 - // const rawBodyText = await appViewResponse.text(); 613 - 614 - // console.log(`🔀 DOING IT Proxying application view request downstream: ${xrpcMethod}`); 615 - // 616 - // // Map status codes and relay headers seamlessly back to client application 617 - // await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 618 - // requestId, 619 - // responseCode: appViewResponse.status, 620 - // responseHeaders: corsHeaders, 621 - // body: safeBtoa(rawBodyText) 622 - // }); 623 - // return; 624 - // } catch (proxyError) { 625 - // console.error("Downstream AppView Proxy Connection Failed:", proxyError); 626 - // await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 627 - // requestId, responseCode: 502, responseHeaders: corsHeaders, 628 - // body: safeBtoa(JSON.stringify({ error: "BadGateway", message: "Failed routing request to downstream AppView." })) 629 - // }); 630 - // return; 631 - // } 632 - //} 633 497 } 634 498 635 - console.log(`DONT HAVE A HANDLER FOR THIS ${pathname}`); 636 - // If it doesn't match our proxy mock platform parameters, let the request proceed over standard internet paths 637 - await chrome.debugger.sendCommand(source, "Fetch.fulfillRequest", { 638 - requestId: requestId, 639 - responseCode: 404, 640 - responseHeaders: [ 641 - { name: "Content-Type", value: "application/json" }, 642 - { name: "Access-Control-Allow-Origin", value: "*" }, 643 - { name: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS, PUT, DELETE" }, 644 - { name: "Access-Control-Allow-Headers", value: "*" } 645 - ], 646 - body: safeBtoa(JSON.stringify({"error": "MethodNotFound", "message": "XRPC method not found"})) 499 + // ------------------------------------------------------------------ 500 + // Unhandled method 501 + // ------------------------------------------------------------------ 502 + console.log(`No handler for: ${xrpcMethod}`); 503 + await fulfill(source, requestId, 404, { 504 + error: "MethodNotFound", message: `XRPC method not found: ${xrpcMethod}` 647 505 }); 648 - return; 649 506 650 507 } catch (error) { 651 - if (error.message && error.message.includes("different extension")) { 652 - console.warn(`[Security Pass] Aborted request owned by another workspace extension.`); 508 + if (error.message?.includes("different extension")) { 509 + console.warn("[Security] Aborted request from another extension."); 653 510 } else { 654 - console.error("Critical Runtime crash inside request handler intercept loop:", error); 511 + console.error("Request handler crash:", error); 655 512 await chrome.debugger.sendCommand(source, "Fetch.continueRequest", { requestId }).catch(() => {}); 656 513 } 657 514 } 658 515 }); 516 + 659 517 } else { 660 - console.error("Debugger API missing. Ensure 'debugger' permission is configured inside your manifest.json."); 518 + console.error("Debugger API missing — add 'debugger' to manifest.json permissions."); 661 519 } 520 + 521 + 522 + connectToSignalingServer(getOrCreateKeypair(), publicKeyJwkToDidKey, getRepo);
+2 -1
manifest.json
··· 5 5 "permissions": ["debugger", "storage", "tabs", "notifications"], 6 6 "host_permissions": ["<all_urls>"], 7 7 "background": { 8 - "service_worker": "background.js" 8 + "service_worker": "background.js", 9 + "type": "module" 9 10 }, 10 11 "options_page": "options.html", 11 12 "action": {
+114
webhook.js
··· 1 + // --------------------------------------------------------------------------- 2 + // WEBSOCKET & BACKEND REPLICATION MANAGEMENT 3 + // --------------------------------------------------------------------------- 4 + 5 + const WS_URL = `ws://ds-pod.com/ws/sync`; // Use wss:// in production 6 + const SECRET_KEY = "your-shared-secret-key"; // Must match Go server exactly 7 + let ws = null; 8 + 9 + export async function connectToSignalingServer(keypair, toDid, getRepo) { 10 + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { 11 + return; 12 + } 13 + 14 + const { privateKey, publicKeyJwk } = await keypair; 15 + const userDid = await toDid(publicKeyJwk); 16 + const repo = await getRepo(userDid, privateKey); 17 + 18 + console.log(`Connecting to sync server as: ${userDid}`); 19 + ws = new WebSocket(WS_URL); 20 + 21 + ws.onopen = () => { 22 + console.log("Connected to Go Sync Server. Registering identity..."); 23 + // 1. Authenticate & Register local peer ID (Your DID) 24 + ws.send(JSON.stringify({ 25 + secret: SECRET_KEY, 26 + type: "register", 27 + peerId: userDid 28 + })); 29 + }; 30 + 31 + ws.onmessage = async (event) => { 32 + try { 33 + const msg = JSON.parse(event.data); 34 + 35 + switch (msg.type) { 36 + case "relay-request": 37 + console.log(`Received remote sync crawl request for URL: ${msg.url}`); 38 + await handleRemoteSyncRequest(msg.requestId, repo); 39 + break; 40 + 41 + case "error": 42 + console.error("Server synchronization error response:", msg.error); 43 + break; 44 + 45 + default: 46 + console.log("Unhandled signaling message:", msg); 47 + } 48 + } catch (err) { 49 + console.error("Error parsing sync message:", err); 50 + } 51 + }; 52 + 53 + ws.onclose = (e) => { 54 + console.warn(`Sync socket closed (${e.reason}). Reconnecting in 5s...`); 55 + setTimeout(connectToSignalingServer, 5000); 56 + }; 57 + 58 + ws.onerror = (err) => { 59 + console.error("Sync socket encountered an error: ", err); 60 + ws.close(); 61 + }; 62 + } 63 + 64 + // Handles inbound XRPC getRepo requests forwarded from the Go server 65 + async function handleRemoteSyncRequest(requestId, repo) { 66 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 67 + 68 + try { 69 + // Export the CAR file bytes from your local engine 70 + const carBytes = await repo.exportCAR(); 71 + 72 + // Convert Uint8Array to base64 binary safely inside service worker 73 + let binary = ''; 74 + const len = carBytes.byteLength; 75 + for (let i = 0; i < len; i++) { 76 + binary += String.fromCharCode(carBytes[i]); 77 + } 78 + const base64Car = btoa(binary); 79 + 80 + // Relay the data payload back to the Go server 81 + ws.send(JSON.stringify({ 82 + secret: SECRET_KEY, 83 + type: "relay-response-data", 84 + requestId: requestId, 85 + carBytes: base64Car 86 + })); 87 + console.log(`Successfully streamed CAR bytes back to relay for req: ${requestId}`); 88 + } catch (err) { 89 + console.error("Failed to generate sync data payload for remote peer:", err); 90 + } 91 + } 92 + 93 + // Proactively fire an update signal when local state alters 94 + //async function broadcastUpdateEvent() { 95 + // if (!ws || ws.readyState !== WebSocket.OPEN) { 96 + // console.warn("Cannot emit live update: WebSocket connection is offline."); 97 + // return; 98 + // } 99 + // 100 + // // Note: If you want to push updates immediately instead of waiting for a crawl, 101 + // // you can append `carBytes` directly into this broadcast message payload, 102 + // // matching what your Go routing layer expects or needs to propagate. 103 + // console.log("Repository mutated locally. Emitting synchronized update state hook..."); 104 + // 105 + // /* ws.send(JSON.stringify({ 106 + // secret: SECRET_KEY, 107 + // type: "repo-mutated", // Ensure your Go server is set up to handle custom push types 108 + // peerId: userDid 109 + // })); 110 + // */ 111 + //} 112 + 113 + // Spin up connection on background script initialization 114 + //connectToSignalingServer();