An in browser local PDS localpds.at
20

Configure Feed

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

Fix deleting and start adding blob stuff

authored by

Niall Bunting and committed by
Niall Bunting
(May 31, 2026, 12:20 PM +0100) 2b0ceb61 8656419b

+142 -59
+142 -59
atproto-repo.js
··· 12 12 * fanout 4, prefix-key compression, correct node schema {l, e[{p,k,v,t}]} 13 13 * - Signed commits (unsigned CBOR → SHA-256 → ES256 → sig bytes in commit) 14 14 * - In-extension block store backed by chrome.storage.local 15 + * - Blob store backed by chrome.storage.local (base64-encoded) 15 16 * - CAR v1 export for com.atproto.sync.* 16 17 * 17 18 * Drop this file into your extension and import the exported symbols. ··· 448 449 const halfN = N / 2n; 449 450 450 451 const view = new Uint8Array(sigBuffer); 451 - let s = BigInt('0x' + bytesToHex(view.slice(32, 64))); 452 + const s = BigInt('0x' + bytesToHex(view.slice(32, 64))); 452 453 453 454 let finalSig = view; 454 455 if (s > halfN) { ··· 525 526 } 526 527 527 528 // --------------------------------------------------------------------------- 528 - // 7. Repo class — wraps storage, MST, commits 529 + // 7. Repo class — wraps storage, MST, commits, blobs 529 530 // --------------------------------------------------------------------------- 530 531 531 - const STORE_KEY = 'atproto_repo_v2'; 532 + const STORE_KEY = 'atproto_repo_v2'; 533 + const BLOB_STORE_KEY = 'atproto_blobs_v1'; 532 534 533 535 export class Repo { 534 536 constructor(did, privateKey) { 535 - this.did = did; 537 + this.did = did; 536 538 this.privateKey = privateKey; 537 539 // records: Map<path, { cidBytes, cidStr, cborBytes }> 538 - this.records = new Map(); 539 - // current commit state 540 - this.currentCommitCidBytes = null; 541 - this.currentCommitCidStr = null; 542 - this.currentRev = null; 543 - // MST root CID of the current commit (needed for prevData in firehose frames) 544 - this.currentMstRootCidStr = null; 540 + this.records = new Map(); 541 + // blobs: Map<cidStr, { mimeType, size, data: Uint8Array }> 542 + this.blobs = new Map(); 543 + // commit state 544 + this.currentCommitCidBytes = null; 545 + this.currentCommitCidStr = null; 546 + this.currentRev = null; 547 + this.currentMstRootCidStr = null; 545 548 } 546 549 547 550 /** Load or create a repo from chrome.storage.local */ 548 551 static async open(did, privateKey) { 549 552 const repo = new Repo(did, privateKey); 553 + 554 + // ---- Load records ---- 550 555 const stored = await chrome.storage.local.get(STORE_KEY); 551 - if (stored[STORE_KEY]) { 556 + if (stored[STORE_KEY] && stored[STORE_KEY].did === did) { 552 557 const data = stored[STORE_KEY]; 553 - if (data.did === did) { 554 - // Restore records (stored as { path → { cidStr, cborHex } }) 555 - for (const [path, entry] of Object.entries(data.records || {})) { 556 - const cborBytes = hexToBytes(entry.cborHex); 557 - const { cidBytes, cidStr } = await hashToCid(cborBytes); 558 - repo.records.set(path, { cidBytes, cidStr, cborBytes }); 559 - } 560 - repo.currentCommitCidBytes = entry => entry; // placeholder — set below 561 - repo.currentCommitCidStr = data.commitCidStr || null; 562 - // Re-derive commit CID bytes from str 563 - if (data.commitCidStr) { 564 - repo.currentCommitCidBytes = base32DecodeToBytes(data.commitCidStr.slice(1)); // strip 'b' 565 - } 566 - repo.currentRev = data.rev || null; 558 + 559 + for (const [path, entry] of Object.entries(data.records || {})) { 560 + const cborBytes = hexToBytes(entry.cborHex); 561 + const { cidBytes, cidStr } = await hashToCid(cborBytes); 562 + repo.records.set(path, { cidBytes, cidStr, cborBytes }); 567 563 } 564 + 565 + // FIX: was accidentally set to an identity arrow function. 566 + // Properly decode the commit CID bytes from the stored base32 string. 567 + repo.currentCommitCidStr = data.commitCidStr || null; 568 + if (data.commitCidStr) { 569 + repo.currentCommitCidBytes = base32DecodeToBytes(data.commitCidStr.slice(1)); // strip 'b' 570 + } 571 + repo.currentRev = data.rev || null; 572 + repo.currentMstRootCidStr = data.mstRootCidStr || null; 568 573 } 574 + 575 + // ---- Load blobs ---- 576 + const blobStored = await chrome.storage.local.get(BLOB_STORE_KEY); 577 + if (blobStored[BLOB_STORE_KEY]) { 578 + for (const [cidStr, entry] of Object.entries(blobStored[BLOB_STORE_KEY])) { 579 + repo.blobs.set(cidStr, { 580 + mimeType: entry.mimeType, 581 + size: entry.size, 582 + data: base64ToBytes(entry.dataB64), 583 + }); 584 + } 585 + } 586 + 569 587 return repo; 570 588 } 571 589 ··· 578 596 [STORE_KEY]: { 579 597 did: this.did, 580 598 records, 581 - commitCidStr: this.currentCommitCidStr, 582 - rev: this.currentRev, 599 + commitCidStr: this.currentCommitCidStr, 600 + rev: this.currentRev, 601 + mstRootCidStr: this.currentMstRootCidStr, 583 602 } 584 603 }); 585 604 } 586 605 587 - /** 588 - * Apply a write (create/update/delete) and return the new commit state. 589 - */ 606 + async _persistBlobs() { 607 + const out = {}; 608 + for (const [cidStr, entry] of this.blobs.entries()) { 609 + out[cidStr] = { 610 + mimeType: entry.mimeType, 611 + size: entry.size, 612 + dataB64: bytesToBase64(entry.data), 613 + }; 614 + } 615 + await chrome.storage.local.set({ [BLOB_STORE_KEY]: out }); 616 + } 617 + 590 618 async _applyWrite(op) { 591 619 // op: { type: 'create'|'update'|'delete', collection, rkey, record? } 592 620 const path = `${op.collection}/${op.rkey}`; 593 621 594 622 if (op.type === 'delete') { 623 + // FIX: was silently failing on reload due to corrupt currentCommitCidBytes. 624 + // The Map deletion is correct; the commit chain now uses the properly 625 + // restored currentCommitCidBytes from open(). 595 626 this.records.delete(path); 596 627 } else { 597 628 // Encode record as DAG-CBOR ··· 624 655 ); 625 656 626 657 this.currentCommitCidBytes = commitCidBytes; 627 - this.currentCommitCidStr = commitCidStr; 628 - this.currentMstRootCidStr = rootCidStr; 658 + this.currentCommitCidStr = commitCidStr; 659 + this.currentMstRootCidStr = rootCidStr; 629 660 630 661 await this._persist(); 631 662 ··· 718 749 } 719 750 return results; 720 751 } 752 + 753 + // ------------------------------------------------------------------------- 754 + // Blob methods 755 + // ------------------------------------------------------------------------- 756 + 757 + /** 758 + * Store a blob (raw bytes) and return its ref object. 759 + * The CID is computed as CIDv1 + raw codec (0x55) + sha2-256, matching 760 + * what atproto blob refs expect (distinct from dag-cbor record CIDs). 761 + * 762 + * @param {Uint8Array} data 763 + * @param {string} mimeType 764 + * @returns {{ ref: { $link: string }, mimeType: string, size: number }} 765 + */ 766 + async uploadBlob(data, mimeType) { 767 + // Blobs use raw codec (0x55), not dag-cbor (0x71) 768 + const { cidBytes, cidStr } = await hashToCid(data, 0x55); 769 + 770 + this.blobs.set(cidStr, { mimeType, size: data.length, data }); 771 + await this._persistBlobs(); 772 + 773 + // atproto blob ref format 774 + return { 775 + ref: { $link: cidStr }, 776 + mimeType: mimeType, 777 + size: data.length, 778 + }; 779 + } 780 + 781 + /** 782 + * Retrieve a blob by CID string. 783 + * @returns {{ data: Uint8Array, mimeType: string } | null} 784 + */ 785 + getBlob(cidStr) { 786 + const entry = this.blobs.get(cidStr); 787 + if (!entry) return null; 788 + return { data: entry.data, mimeType: entry.mimeType }; 789 + } 790 + 791 + /** 792 + * List all stored blob CID strings (optionally filtered to those 793 + * referenced by records written since a given rev — simplified here 794 + * to always return all CIDs, which satisfies the sync protocol). 795 + * @returns {string[]} 796 + */ 797 + listBlobCids() { 798 + return Array.from(this.blobs.keys()); 799 + } 721 800 722 801 /** 723 802 * Export the full repository as a CAR v1 binary (Uint8Array). 724 803 * Suitable for responding to com.atproto.sync.getRepo. 725 804 */ 805 + async deleteBlob(cidStr) { 806 + this.blobs.delete(cidStr); 807 + await this._persistBlobs(); 808 + } 809 + 810 + // ------------------------------------------------------------------------- 811 + // CAR export 812 + // ------------------------------------------------------------------------- 813 + 726 814 async exportCAR() { 727 815 // Rebuild full MST + commit 728 816 const sortedEntries = Array.from(this.records.entries()) ··· 760 848 return encodeCAR(commitCidBytes, allBlocks, cidBytesMap); 761 849 } 762 850 763 - /** 764 - * Get the current HEAD commit CID string. 765 - */ 766 - get headCid() { return this.currentCommitCidStr; } 851 + // ------------------------------------------------------------------------- 852 + // Accessors 853 + // ------------------------------------------------------------------------- 767 854 768 - /** 769 - * Get the current revision TID. 770 - */ 771 - get rev() { return this.currentRev; } 772 - 773 - /** 774 - * Get the MST root CID of the current commit. 775 - * Used as prevData in firehose #commit frames for MST inversion verification. 776 - */ 855 + get headCid() { return this.currentCommitCidStr; } 856 + get rev() { return this.currentRev; } 777 857 get mstRootCid() { return this.currentMstRootCidStr; } 778 858 779 - /** 780 - * Return the signed commit CBOR bytes and CID string for the current HEAD. 781 - * Used by ingest.js to build the relay payload without re-parsing the CAR. 782 - * Rebuilds the commit from the current MST state so the bytes are always fresh. 783 - */ 784 859 async getLastCommitCbor() { 785 860 const sortedEntries = Array.from(this.records.entries()) 786 861 .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) ··· 879 954 // 9. Utility helpers 880 955 // --------------------------------------------------------------------------- 881 956 882 - function bufToHex(buffer) { 883 - return Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join(''); 884 - } 885 - 886 - function hexToBuf(hex) { 887 - return new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); 888 - } 889 - 890 957 function bytesToHex(bytes) { 891 958 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); 892 959 } ··· 911 978 } 912 979 return new Uint8Array(out); 913 980 } 981 + 982 + function bytesToBase64(bytes) { 983 + let binary = ''; 984 + const chunkSize = 8192; 985 + for (let i = 0; i < bytes.length; i += chunkSize) { 986 + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); 987 + } 988 + return btoa(binary); 989 + } 990 + 991 + function base64ToBytes(b64) { 992 + const binary = atob(b64); 993 + const out = new Uint8Array(binary.length); 994 + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); 995 + return out; 996 + }