OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / c484771

serve-membrane: derive-on-read get-repo-car + make the repo projection optional
Groundwork for SPEC-atproto-overlay-storage §6.3 — "the virtual device stores nothing of its own — it derives." projectRepo copies EVERY record into an append-only Hyperbee (membrane-repo:<did>), which a consumer then replicates again. So record bytes exist THREE times — the content-addressed files, this core, and the consumer's replica — for data the files already hold in full. The core is an ADAPTER made of storage: it exists only because storage speaks content-addressed files while the consumer speaks Hypercore replication. Two additive changes, no behaviour change by default: 1. `get-repo-car` RPC on the existing membrane channel — device.exportCar() rebuilds the MST + CAR from the record FILES on demand and asserts the rebuilt data root equals the signed head's, so it can only serve a repo matching its own signature. Nothing is stored to answer it. 2. `projectRepoCore` option (default TRUE). When false the membrane-repo Hyperbee is never opened and projectRepo never runs. It is an OPTION rather than a deletion because the announce still carries repoKey and atproto-did-peer's onAnnounce/holds/_announceTo all gate on it — a peer that stops projecting is simply not fronted by an unpatched consumer. repoKey/repoLength go null in that mode, which degrades gracefully: the consumer keeps using a co-owner that still projects. That makes the flag safe to roll out one node at a time instead of as a flag day. GATED ON AN EQUIVALENCE PROOF (hyper-proto-peer test/derive-vs-project): the derived CAR is BYTE-IDENTICAL to the projected one — same commit CID, same MST root, same CAR bytes. Deleting the projection therefore cannot change what bsky sees. Also pinned: derive refuses a tampered record set, and an unsigned record cannot leak into a served CAR. Suite 170/170. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c484771c0a5e0a3b6ebbbcf014d3471f372866a5
by plankatron <fleeky@gmail.com> · 2026-07-26T22:52:59.000Z

Changed files (2) +33 -6

lib/serve-membrane.js +32 -5
……@@ -75,9 +75,19 @@ function revTime (rev) {
7575// serveMembrane — project the stores + PUSH the announce + replicate the corestore over `swarm`.
7676// Mirrors peer.serve: joins the DID discovery topic + the serverKey topic; on connect, replicates
7777// the corestore and pushes an announce. Call `sync()` after new signs to project the latest state.
78export async function serveMembrane (device, { corestore, swarm, did, handle, sessions = null, blobs = null } = {}) {
78// `projectRepoCore` (default true) — when FALSE the membrane-repo Hyperbee is never opened and
79// projectRepo never runs, so record bytes exist ONLY as the content-addressed files. Consumers then
80// read the repo via the `get-repo-car` RPC above (derived on demand) or, when co-located, straight off
81// the shared overlay files. This is SPEC §6.3's "the virtual device holds nothing".
82//
83// It is an OPTION rather than a deletion because the announce contract still carries `repoKey`, and
84// atproto-did-peer's onAnnounce/holds/_announceTo all gate on it — a peer that stops projecting is
85// simply not fronted by an unpatched consumer. Flipping this to false is therefore a COORDINATED
86// change with the consumer; shipping the flag first lets the derive-on-read surface land with zero
87// behavioural change and be verified in place before anything is removed.
88export async function serveMembrane (device, { corestore, swarm, did, handle, sessions = null, blobs = null, projectRepoCore = true } = {}) {
7989 if (!corestore || !swarm || !did) throw new Error('serveMembrane: corestore, swarm, did required')
80 const repo = await new RepoStore(corestore, { name: 'membrane-repo:' + did }).ready()
90 const repo = projectRepoCore ? await new RepoStore(corestore, { name: 'membrane-repo:' + did }).ready() : null
8191 const seq = await new FirehoseSequencer(corestore.get({ name: 'membrane-firehose:' + did })).ready()
8292 // Project ONLY when the device's __commit__ log has advanced past what the membrane firehose already
8393 // holds. WITHOUT this gate, the caller's sign loop (every signIntervalMs, ~2s) called sync()
……@@ -89,13 +99,13 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
8999 const sync = async () => {
90100 const log = await device.firehoseLog()
91101 if (log.length <= seq.length) return
92 await projectRepo(device, repo)
102 if (repo) await projectRepo(device, repo) // skipped entirely when the repo core is off
93103 await projectFirehose(device, seq, log)
94104 }
95105 await sync()
96106
97107 const serverKey = b4a.toString(swarm.keyPair.publicKey, 'hex')
98 const repoKey = b4a.toString(repo.core.key, 'hex')
108 const repoKey = repo ? b4a.toString(repo.core.key, 'hex') : null
99109 const firehoseKey = b4a.toString(seq.core.key, 'hex')
100110 const onconn = (conn) => {
101111 corestore.replicate(conn)
……@@ -110,13 +120,30 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
110120 if (achan) {
111121 const amsg = achan.addMessage({ encoding: c.json, onmessage: () => {} })
112122 achan.open()
113 const pushAnnounce = () => { try { amsg.send({ did, handle: handle || null, repoKey, firehoseKey, repoLength: repo.core.length, firehoseLength: seq.core.length }) } catch {} }
123 // repoKey/repoLength are null when the repo core is off — a consumer that requires them simply
124 // does not front this peer (it keeps using a co-owner that still projects), which is the
125 // graceful degradation that makes the flag safe to roll out one node at a time.
126 const pushAnnounce = () => { try { amsg.send({ did, handle: handle || null, repoKey, firehoseKey, repoLength: repo ? repo.core.length : null, firehoseLength: seq.core.length }) } catch {} }
114127 pushAnnounce()
115128 const onapp = () => pushAnnounce()
116129 seq.core.on('append', onapp)
117130 conn.on('close', () => { try { seq.core.off('append', onapp) } catch {} })
118131 }
119132 } catch {}
133 // get-repo-car (com.atproto.sync.getRepo, DERIVED) — the stateless replacement for the
134 // membrane-repo projection core. device.exportCar() rebuilds the MST + CAR from the record FILES on
135 // demand and asserts the rebuilt data root equals the signed head's, so it serves nothing that does
136 // not match its own signature. Nothing is stored to answer this.
137 //
138 // WHY: projectRepo copies EVERY record into an append-only Hyperbee (membrane-repo:<did>), which a
139 // consumer then replicates again — so record bytes live three times (files, this core, the
140 // consumer's replica) for data the files already hold. SPEC-atproto-overlay-storage §6.3: "the
141 // virtual device stores nothing of its own — it derives." This is that surface. PUBLIC, like
142 // get-blob: a repo CAR is public atproto data and is self-verifying against the DID key.
143 rpc.respond('get-repo-car', async () => {
144 const r = await device.exportCar()
145 return { car: b4a.toString(r.carBytes, 'hex'), commitCid: r.commitCid, dataCid: r.dataCid, rev: r.rev, recordCount: r.recordCount }
146 })
120147 // get-blob (com.atproto.sync.getBlob) — PUBLIC: atproto blobs are public bytes addressed by CID.
121148 // Blobs live in the PEER's BlobStore; the keyless bridge relays this RPC (peer-front.getBlob) and
122149 // streams the bytes back. null → the bridge answers 404. bytes are hex in the c.json RPC value.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.11",
3 "version": "0.2.12",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",