OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 8308e38

atproto-base: Phase 0 #4 — repo-for-bridge projection (getRepo + repoKey)
The bridge's PeerFront.getRepo assembles a CAR from a replicated RepoStore + the firehose's signed commit (getRepoFromCommit, which ASSERTS the RepoStore MST root == the commit's data). atproto-base now exposes that repo: a RepoProjection (put/del/ records over its OWN corestore) kept in sync with the finalized record set on each emitFirehose, plus getRepo() = getRepoFromCommit(repo, lastSignedCommit), and repoKey/firehoseKey for the bridge to replicate. Used an inline RepoProjection instead of atproto-store's RepoStore because the latter's `storage instanceof Corestore` guard fails across duplicated corestore module copies (atproto-store is a symlinked file: dep). getRepoFromCommit only needs records()->[{key, bytes}]; CIDs are content-addressed so cross-module rebuild matches. Proven: getRepo CAR is byte-exact (verifyRepo), consistent with the emitted head, reflects create+delete, verifies against the #atproto key; throws before first emit. Phase 0 gap #4 closed. Only #5 (migration reader) remains. test/repo-projection.test.mjs (2 tests / 7 asserts). Suite now 27 tests / 114 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8308e382b2766183d0195d4098c817f847800004
by plankatron <fleeky@gmail.com> · 2026-07-23T19:56:32.000Z

Changed files (2) +90 -2

lib/base.js +40 -2
……@@ -23,6 +23,8 @@ import Hypercore from 'hypercore'
2323import b4a from 'b4a'
2424import crypto from 'hypercore-crypto'
2525import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
26import { getRepoFromCommit } from 'atproto-store/lib/read-view.js'
27import { cidForValue } from 'atproto-repo/lib/cid.js'
2628import { projectCommit, nextRev } from './project.js'
2729import { buildCommitWrite } from './firehose.js'
2830import { revTime } from './rev.js'
……@@ -57,6 +59,20 @@ function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signer
5759// key — a non-member has neither, so they can neither locate nor decrypt the co-owned base.
5860function deriveEncKey (roomSeed) { return crypto.hash([ENC_NAMESPACE, toBuf(roomSeed)]) }
5961
62// A minimal RepoStore-compatible projection (put/del/records) over atproto-base's OWN corestore. We
63// don't reuse atproto-store's RepoStore because its `storage instanceof Corestore` guard fails across
64// duplicated corestore module copies (atproto-store is a symlinked file: dep with its own node_modules).
65// getRepoFromCommit only reads records() -> [{key, bytes}]; record bytes are canonical DAG-CBOR
66// (cidForValue), so CIDs match atproto-repo's regardless of which module copy computed them.
67class RepoProjection {
68 constructor (core) { this.core = core; this.bee = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'binary', alwaysDuplicate: false }) }
69 async ready () { await this.core.ready(); await this.bee.ready(); return this }
70 async put (collection, rkey, record) { const { bytes } = await cidForValue(record); await this.bee.put(`${collection}/${rkey}`, b4a.from(bytes)) }
71 async del (collection, rkey) { await this.bee.del(`${collection}/${rkey}`) }
72 async records () { const out = []; for await (const n of this.bee.createReadStream()) out.push({ key: String(n.key), bytes: n.value }); return out }
73 async close () { try { await this.bee.close() } catch {} }
74}
75
6076export class AtprotoBase {
6177 // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey, identity?, members? }
6278 // identity: { publicKey, secretKey } — this co-owner's membership key (ed25519, drives admission).
……@@ -91,7 +107,8 @@ export class AtprotoBase {
91107 this._ackInterval = opts.ackInterval || 1000
92108 this._admitted = new Set()
93109 this._firehose = null // FirehoseSequencer (Hypercore of #commit frames)
94 this._prevHead = { records: [], rev: null, commitCid: null } // last EMITTED firehose head (for diffing)
110 this._repo = null // RepoStore projection (getRepo for the bridge)
111 this._prevHead = { records: [], rev: null, commitCid: null, signedCommit: null } // last EMITTED head
95112 this.store = null
96113 this.base = null
97114 this._ready = false
……@@ -127,6 +144,9 @@ export class AtprotoBase {
127144 // The public firehose: a plain Hypercore of #commit/#sync frames the bridge replicates + serves as
128145 // com.atproto.sync.subscribeRepos. Separate from the autobase (a projection of it), like the membrane.
129146 this._firehose = await new FirehoseSequencer(this.store.get({ name: 'atproto-firehose' })).ready()
147 // The public repo core the bridge's getRepo assembles from (a RepoStore kept in sync with the
148 // finalized record set on each emitFirehose, so its MST root matches the emitted signed commit).
149 this._repo = await new RepoProjection(this.store.get({ name: 'atproto-repo-projection' })).ready()
130150 this._ready = true
131151 if (this._members) this.projectMembers(this._members).catch(() => {}) // creator best-effort projection
132152 }
……@@ -431,17 +451,35 @@ export class AtprotoBase {
431451 did: this._did, rev, since: this._prevHead.rev, prev: this._prevHead.commitCid, privateKey: this._privateKey
432452 })
433453 if (write.ops.length === 0 && this._prevHead.commitCid) return null // nothing finalized-new to emit
454 await this._projectRepo(records) // keep the repo core in sync FIRST so getRepo() matches this commit
434455 await this._firehose.sequenceCommit(write, { time: revTime(rev) })
435 this._prevHead = { records, rev, commitCid: write.commitCid }
456 this._prevHead = { records, rev, commitCid: write.commitCid, signedCommit: write.signedCommit }
436457 return write
437458 }
438459
460 // Sync the RepoStore projection to the finalized record set (put changed, delete removed) so its MST
461 // root equals the emitted commit's data CID (getRepoFromCommit asserts this).
462 async _projectRepo (records) {
463 const want = new Set(records.map((r) => r.key))
464 for (const r of records) { const i = r.key.lastIndexOf('/'); await this._repo.put(r.key.slice(0, i), r.key.slice(i + 1), r.value) }
465 for (const e of await this._repo.records()) { if (!want.has(e.key)) { const i = e.key.lastIndexOf('/'); await this._repo.del(e.key.slice(0, i), e.key.slice(i + 1)) } }
466 }
467
468 // com.atproto.sync.getRepo — the signed CAR the bridge serves, assembled from the RepoStore + the last
469 // emitted signed commit (exactly PeerFront.getRepo's contract). Call emitFirehose() at least once first.
470 async getRepo () {
471 if (!this._prevHead.signedCommit) throw new Error('AtprotoBase.getRepo: no commit emitted yet — call emitFirehose() first')
472 return getRepoFromCommit(this._repo, this._prevHead.signedCommit)
473 }
474
439475 subscribeRepos (cursor = 0, opts) { return this._firehose.subscribe(cursor, opts) }
440476 get firehoseKey () { return this._firehose ? this._firehose.core.key : null }
441477 get firehoseLength () { return this._firehose ? this._firehose.length : 0 }
478 get repoKey () { return this._repo ? this._repo.core.key : null }
442479
443480 async close () {
444481 if (this._firehose) { try { await this._firehose.close() } catch {} this._firehose = null }
482 if (this._repo) { try { await this._repo.close() } catch {} this._repo = null }
445483 if (this.base) { try { await this.base.close() } catch {} this.base = null }
446484 if (this.store && !this._injectedStore) { try { await this.store.close() } catch {} }
447485 this._ready = false
test/repo-projection.test.mjs +50 -0
……@@ -0,0 +1,50 @@
1// PHASE 0 #4 — REPO-FOR-BRIDGE PROJECTION. The bridge's PeerFront.getRepo assembles a CAR from a
2// replicated RepoStore + the firehose's signed commit (getRepoFromCommit, which ASSERTS the RepoStore's
3// MST root == the commit's data). atproto-base now exposes that RepoStore (repoKey) kept in sync with
4// each emitted head, plus getRepo() — so the bridge can serve com.atproto.sync.getRepo from it.
5
6import test from 'brittle'
7import { mkdtempSync, rmSync } from 'node:fs'
8import { tmpdir } from 'node:os'
9import { join } from 'node:path'
10import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
11import { readCar } from 'atproto-repo/lib/car.js'
12import { verifyRepo } from 'atproto-repo'
13import { verifyCommit } from 'atproto-repo/lib/commit.js'
14import { AtprotoBase } from '../lib/base.js'
15
16const MNEMONIC = 'test test test test test test test test test test test junk'
17const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
18const KEY = deriveAtprotoKey(MNEMONIC)
19const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
20function mkBase () { const dir = mkdtempSync(join(tmpdir(), 'repo-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100 }); return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } }
21const recCount = async (car) => (await verifyRepo(await readCar(car))).records
22
23test('getRepo(): valid CAR, consistent with the emitted head, reflects create+delete', async (t) => {
24 const A = mkBase(); await A.base.ready()
25 t.teardown(() => A.close())
26
27 await A.base.putRecord('app.bsky.feed.post', '3a', post('one'))
28 await A.base.putRecord('app.bsky.feed.post', '3b', post('two'))
29 const w = await A.base.emitFirehose()
30
31 t.ok(A.base.repoKey && A.base.firehoseKey, 'repoKey + firehoseKey exposed for the bridge to replicate')
32
33 const repo = await A.base.getRepo()
34 const v = await verifyRepo(await readCar(repo.carBytes))
35 t.ok(v.ok, `getRepo CAR is byte-exact (verifyRepo), mismatches=${JSON.stringify(v.mismatches)}`)
36 t.is(v.records, 2, 'both records in the served repo')
37 t.is(repo.commitCid.toString(), w.commitCid.toString(), 'getRepo commit == the emitted firehose head (RepoStore stays consistent with the commit)')
38 t.ok(verifyCommit(w.signedCommit, KEY.publicKey), 'the served commit verifies against the #atproto key')
39
40 await A.base.delRecord('app.bsky.feed.post', '3a')
41 await A.base.emitFirehose()
42 t.is(await recCount((await A.base.getRepo()).carBytes), 1, 'delete is reflected in the served repo')
43})
44
45test('getRepo(): throws before any commit is emitted', async (t) => {
46 const A = mkBase(); await A.base.ready()
47 t.teardown(() => A.close())
48 await A.base.putRecord('app.bsky.feed.post', '3a', post('one'))
49 await t.exception(() => A.base.getRepo(), 'getRepo before emitFirehose throws (no commit to anchor the CAR)')
50})