OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 098a622

serveMembrane: project a labeler DID's signed labels and announce them (0.2.28)
The signed __label__ log lives in the StateBase, which only co-owners replicate. A keyless consumer (the gateway answering bsky's queryLabels and subscribeLabels) reaches a DID only through the membrane announce. With `labels` (a LabelRoom), a membrane-labels:<did> core is opened, syncLabels() projects the log into it, and the announce carries labelKey/labelLength, re-sent on each label append. Without it: no core, labelKey:null. The announce payload now comes from one announcement() function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
098a622ed2d6b28754fe1a295daf50c031c178aa
by plankatron <fleeky@gmail.com> · 2026-10-14T02:02:54.000Z

Changed files (4) +90 -8

lib/serve-membrane.js +21 -7
……@@ -201,7 +201,12 @@ function revTime (rev) {
201201// simply not fronted by an unpatched consumer. Flipping this to false is therefore a COORDINATED
202202// change with the consumer; shipping the flag first lets the derive-on-read surface land with zero
203203// behavioural change and be verified in place before anything is removed.
204export async function serveMembrane (device, { corestore, swarm, did, handle, sessions = null, blobs = null, projectRepoCore = true } = {}) {
204// `labels` — a LabelRoom (or anything with projectTo(core)) for a LABELER DID. Its signed __label__ log
205// lives in the StateBase, which only co-owners replicate; a keyless consumer (the gateway answering bsky's
206// queryLabels/subscribeLabels) reaches the DID only through this announce. So the log is projected into a
207// plain `membrane-labels:<did>` hypercore and named as labelKey/labelLength, the firehose pattern again.
208// Absent = no core opened and labelKey:null in the announce (the field exists, so a consumer can branch).
209export async function serveMembrane (device, { corestore, swarm, did, handle, sessions = null, blobs = null, projectRepoCore = true, labels = null } = {}) {
205210 if (!corestore || !swarm || !did) throw new Error('serveMembrane: corestore, swarm, did required')
206211 // Step markers. This function is `await`ed during a DID's boot, so anything slow here stalls the whole
207212 // node — and on a phone it stalled with NO output at all, leaving "somewhere inside serveMembrane" as
……@@ -213,6 +218,11 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
213218 step(projectRepoCore ? 'repo core ready' : 'repo core SKIPPED (virtual device)')
214219 const seq = await new FirehoseSequencer(corestore.get({ name: 'membrane-firehose:' + did })).ready()
215220 step('firehose sequencer ready')
221 const labelCore = labels ? corestore.get({ name: 'membrane-labels:' + did }) : null
222 if (labelCore) { await labelCore.ready(); step('label core ready') }
223 // Incremental and idempotent (LabelRoom.projectTo appends only labels the core lacks). Called by the
224 // host right after the label signer's tick; returns the count appended.
225 const syncLabels = async () => (labels && labelCore ? labels.projectTo(labelCore) : 0)
216226 // Project ONLY when the device's __commit__ log has advanced past what the membrane firehose already
217227 // holds. WITHOUT this gate, the caller's sign loop (every signIntervalMs, ~2s) called sync()
218228 // UNCONDITIONALLY, and projectRepo re-put ALL records into the APPEND-ONLY Hyperbee repo core on EVERY
……@@ -286,6 +296,11 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
286296 const serverKey = b4a.toString(swarm.keyPair.publicKey, 'hex')
287297 const repoKey = repo ? b4a.toString(repo.core.key, 'hex') : null
288298 const firehoseKey = b4a.toString(seq.core.key, 'hex')
299 const labelKey = labelCore ? b4a.toString(labelCore.key, 'hex') : null
300 // The announce payload, one place. repoKey/repoLength are null when the repo core is off — a consumer
301 // that requires them simply does not front this peer (it keeps using a co-owner that still projects),
302 // which is the graceful degradation that makes the flag safe to roll out one node at a time.
303 const announcement = () => ({ did, handle: handle || null, repoKey, firehoseKey, repoLength: repo ? repo.core.length : null, firehoseLength: seq.core.length, labelKey, labelLength: labelCore ? labelCore.length : null })
289304 const onconn = (conn) => {
290305 corestore.replicate(conn)
291306 const rpc = new ProtomuxRPC(conn, { protocol: RPC_PROTOCOL, valueEncoding: c.json })
……@@ -299,14 +314,13 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
299314 if (achan) {
300315 const amsg = achan.addMessage({ encoding: c.json, onmessage: () => {} })
301316 achan.open()
302 // repoKey/repoLength are null when the repo core is off — a consumer that requires them simply
303 // does not front this peer (it keeps using a co-owner that still projects), which is the
304 // graceful degradation that makes the flag safe to roll out one node at a time.
305 const pushAnnounce = () => { try { amsg.send({ did, handle: handle || null, repoKey, firehoseKey, repoLength: repo ? repo.core.length : null, firehoseLength: seq.core.length }) } catch {} }
317 const pushAnnounce = () => { try { amsg.send(announcement()) } catch {} }
306318 pushAnnounce()
307319 const onapp = () => pushAnnounce()
308320 seq.core.on('append', onapp)
309 conn.on('close', () => { try { seq.core.off('append', onapp) } catch {} })
321 // A new label is news to the consumer just like a new commit: re-announce so it extends its replica.
322 if (labelCore) labelCore.on('append', onapp)
323 conn.on('close', () => { try { seq.core.off('append', onapp); if (labelCore) labelCore.off('append', onapp) } catch {} })
310324 }
311325 } catch {}
312326 // get-repo-car (com.atproto.sync.getRepo, DERIVED) — the stateless replacement for the
……@@ -409,7 +423,7 @@ export async function serveMembrane (device, { corestore, swarm, did, handle, se
409423 const dDid = swarm.join(discoveryTopic(did), { server: true, client: true })
410424 step('topics joined — serveMembrane returning (the announce flush is the CALLER\'s to await)')
411425 return {
412 repo, seq, serverKey, repoKey, firehoseKey, sync,
426 repo, seq, serverKey, repoKey, firehoseKey, sync, labelCore, labelKey, syncLabels, announcement,
413427 ready: () => Promise.all([dKey.flushed(), dDid.flushed()]),
414428 close: () => swarm.off('connection', onconn)
415429 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.27",
3 "version": "0.2.28",
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",
test/all.js +1 -0
……@@ -6,6 +6,7 @@ import '../lib/bare-globals.js'
66import './repo-base.test.js'
77import './membrane-stale-gate.test.mjs'
88import './project-repo-core-off.test.mjs'
9import './membrane-labels.test.mjs'
910import './membrane-projection-drift.test.mjs'
1011import './lease.test.js'
1112import './lease-handoff.test.js'
test/membrane-labels.test.mjs +67 -0
……@@ -0,0 +1,67 @@
1// serveMembrane `labels` — a labeler DID's signed label log, served beside its firehose (hyper-social #80,
2// member labels step 2).
3//
4// The shared __label__ log lives in the DID's StateBase, which only co-owners replicate. A keyless
5// consumer (the gateway's lens, answering bsky's queryLabels/subscribeLabels) reaches a DID through the
6// membrane announce, so the labels must be projected into a plain hypercore and named there — the same
7// pattern as the firehose. Without `labels` nothing changes: no core is opened and the announce carries
8// labelKey:null, so a consumer can branch on the field.
9//
10// Run: bare test/membrane-labels.test.mjs
11import '../lib/bare-globals.js'
12import test from 'brittle'
13import b4a from 'b4a'
14import fs from 'bare-fs'
15import { serveMembrane } from '../lib/serve-membrane.js'
16import { LabelRoom } from '../lib/label-room.js'
17import { openCorestore } from 'atproto-store/lib/corestore.js'
18import { decodeLabel } from 'atproto-identity/lib/labels.js'
19
20const DID = 'did:plc:membranelabels000000000'
21const tmp = () => { const d = '/tmp/ar-ml-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
22const fakeSwarm = () => ({ on () {}, off () {}, keyPair: { publicKey: b4a.alloc(32, 7) }, join () { return { flushed: async () => {} } } })
23const fakeDevice = () => ({ repo: { listRecords: async () => [] }, async firehoseLog () { return [] } })
24
25function mockSb () {
26 const m = new Map()
27 return {
28 async set (k, v) { m.set(k, v) },
29 async del (k) { m.delete(k) },
30 async get (k) { return m.has(k) ? m.get(k) : null },
31 async list (prefix) { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o }
32 }
33}
34
35const KEY = b4a.from('0b1b6cd4c1ef3ea5a8b0f9c2c5e0d3e7a1c4f6b8d0e2f4a6c8e0b2d4f6a8c0e2', 'hex')
36
37test('★ labels: the signed log is projected into a served core and named in the announce', async (t) => {
38 const room = new LabelRoom(mockSb(), { src: DID, privateKey: KEY, holds: () => true })
39 await room.ensure('did:plc:member', 'loser-club-member', { cts: '2026-10-13T00:00:00.000Z' })
40 await room.tick()
41
42 const m = await serveMembrane(fakeDevice(), { corestore: openCorestore(tmp()), swarm: fakeSwarm(), did: DID, labels: room })
43 t.ok(m.labelKey, 'a label core is opened and its key exported')
44 const a = m.announcement()
45 t.is(a.labelKey, m.labelKey, 'the announce names it')
46 t.is(a.labelLength, 0, 'nothing projected before the first syncLabels')
47
48 t.is(await m.syncLabels(), 1, 'syncLabels appends the signed label')
49 t.is(m.announcement().labelLength, 1, 'and the announce reports the new length')
50 const l = decodeLabel(await m.labelCore.get(0))
51 t.is(l.src, DID)
52 t.is(l.uri, 'did:plc:member')
53 t.is(l.val, 'loser-club-member')
54
55 t.is(await m.syncLabels(), 0, 'idempotent: nothing new, nothing appended')
56})
57
58test('without labels nothing changes: no core, labelKey:null, syncLabels is a no-op', async (t) => {
59 const m = await serveMembrane(fakeDevice(), { corestore: openCorestore(tmp()), swarm: fakeSwarm(), did: DID })
60 t.is(m.labelKey, null)
61 t.is(m.labelCore, null)
62 const a = m.announcement()
63 t.is(a.labelKey, null, 'the field exists, null')
64 t.is(a.labelLength, null)
65 t.is(await m.syncLabels(), 0)
66 t.ok(a.firehoseKey, 'the existing announce fields are unchanged')
67})