OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 6963ae8

0.2.26: LabelRoom on the host's presence election, with a content-keyed signed log
hyper-social #80 (loser-club member labels): - holds: an injected signer election (hpp's SignerPresence). LabelRoom then runs no election and writes no __lease__ — hpp retired that append-forever heartbeat, and two elections could disagree. Lease mode stays for callers without holds. - The __label__ log is keyed by the label (cts, uri, val, neg), not pad(n): two signers during a partition both wrote __label__\x00<n> with different labels and last-write-wins silently dropped one. - projectTo appends every label the core does not hold (a label replicated in later can sort before ones already projected), idempotently. Tests first: test/label-room-presence.test.mjs (red: holds unsupported); the label units (room, base, signer, presence) pass. Full suite before release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6963ae861ff9cf572020e91260b756d86d51536c
by plankatron <fleeky@gmail.com> · 2026-10-14T01:58:26.000Z

Changed files (3) +118 -13

lib/label-room.js +37 -11
……@@ -19,19 +19,31 @@ import { encodeLabel, decodeLabel } from 'atproto-identity/lib/labels.js'
1919
2020const SIGNED_NS = '__label__' // reserved (leading __) — never a repo record or a label intent
2121const SEP = '\x00'
22const pad = (n) => String(n).padStart(12, '0') // zero-pad so the shared log sorts in append order
2322const stateOf = (l) => l.uri + SEP + l.val + SEP + String(l.cts) + SEP + (l.neg ? '1' : '0')
23// ⛔ CONTENT-KEYED, not index-keyed. The log used to be keyed `pad(n)` (n = the list length the signer saw),
24// so two signers during a partition both wrote __label__\x00<n> with DIFFERENT labels and last-write-wins
25// silently dropped one. A key derived from the label itself cannot collide with a different label, and
26// the same label signed twice (RFC6979: byte-identical) lands on the same key. cts first, so the log still
27// reads roughly in time order.
28const signedKey = (l) => SIGNED_NS + SEP + String(l.cts) + SEP + l.uri + SEP + l.val + SEP + (l.neg ? '1' : '0')
2429
2530export class LabelRoom {
2631 // statebase: shared StateBase (mods = writers). src: labeler DID. privateKey: shared #atproto_label
2732 // key. self: this mod's device id (drives the lease). ttlMs: lease TTL.
28 constructor (statebase, { src, privateKey, self, ttlMs = 15000 } = {}) {
29 if (!src || !privateKey || !self) throw new Error('LabelRoom: src + privateKey + self required')
33 //
34 // `holds`: () => boolean | Promise<boolean> — the HOST's signer election (hpp's presence election). When
35 // given, LabelRoom runs no election of its own and writes no __lease__: hpp retired that append-forever
36 // heartbeat, and two elections could disagree about who signs. Without it, the original Lease (self/ttlMs).
37 constructor (statebase, { src, privateKey, self, ttlMs = 15000, holds = null } = {}) {
38 if (!src || !privateKey) throw new Error('LabelRoom: src + privateKey required')
39 if (!holds && !self) throw new Error('LabelRoom: self required (or pass holds: the host\'s signer election)')
3040 this.sb = statebase
3141 this.src = src
3242 this.privateKey = privateKey
3343 this.base = new LabelBase(statebase, { src })
34 this.lease = new Lease(statebase, { self, ttlMs })
44 this.holds = holds
45 this.lease = holds ? null : new Lease(statebase, { self, ttlMs })
46 this._projected = new WeakMap() // feed core -> Set of label hexes already appended
3547 }
3648
3749 // A mod records a label intent (label/negate). Authority to WRITE is StateBase admission.
……@@ -50,28 +62,42 @@ export class LabelRoom {
5062 // each participant's feed is byte-identical — so the labeler is serveable from ANY node (the
5163 // serve-membrane pattern). Consumers (favela readLabelFeed) get plain signed labels. Returns the count
5264 // appended this call.
65 //
66 // Appends every label in the log the core does not already hold (in key order), not "entries past the
67 // core's length": with content keys a label replicated in later can sort BEFORE ones already projected.
5368 async projectTo (feedCore) {
5469 await feedCore.ready()
70 let have = this._projected.get(feedCore)
71 if (!have || have.size > feedCore.length) {
72 have = new Set()
73 for (let i = 0; i < feedCore.length; i++) have.add(b4a.toString(await feedCore.get(i), 'hex'))
74 this._projected.set(feedCore, have)
75 }
5576 const map = await this.sb.list(SIGNED_NS + SEP)
56 const keys = Object.keys(map).sort()
5777 let appended = 0
58 for (let i = feedCore.length; i < keys.length; i++) { await feedCore.append(b4a.from(map[keys[i]], 'hex')); appended++ }
78 for (const k of Object.keys(map).sort()) {
79 const hex = map[k]
80 if (have.has(hex)) continue
81 await feedCore.append(b4a.from(hex, 'hex'))
82 have.add(hex)
83 appended++
84 }
5985 return appended
6086 }
6187
6288 // One tick: renew/claim the lease; if we hold it, sign intents not yet in the shared __label__ log
6389 // and append them. Returns the count appended (0 if not the lease-holder). Failover-safe.
6490 async tick (now = Date.now()) {
65 if (!(await this.lease.renewOrClaim(now))) return 0
91 const mine = this.holds ? await this.holds() : await this.lease.renewOrClaim(now)
92 if (!mine) return 0
6693 const logMap = await this.sb.list(SIGNED_NS + SEP)
67 const keys = Object.keys(logMap).sort()
68 const emitted = new Set(keys.map((k) => stateOf(decodeLabel(b4a.from(logMap[k], 'hex')))))
69 let n = keys.length
94 const emitted = new Set(Object.keys(logMap).map((k) => stateOf(decodeLabel(b4a.from(logMap[k], 'hex')))))
7095 let count = 0
7196 for (const i of await this.base.intents()) {
7297 if (emitted.has(stateOf(i))) continue
7398 const signed = materializeLabel(i, { src: this.src, privateKey: this.privateKey })
74 await this.sb.set(SIGNED_NS + SEP + pad(n++), b4a.toString(encodeLabel(signed), 'hex'))
99 await this.sb.set(signedKey(signed), b4a.toString(encodeLabel(signed), 'hex'))
100 emitted.add(stateOf(i))
75101 count++
76102 }
77103 return count
package.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.25",
3 "version": "0.2.26",
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",
77 "scripts": {
88 "test": "bare test/all.js",
9 "test:units": "node test/label-base.test.mjs && node test/label-signer.test.mjs && node test/label-room.test.mjs",
9 "test:units": "node test/label-base.test.mjs && node test/label-signer.test.mjs && node test/label-room.test.mjs && node test/label-room-presence.test.mjs",
1010 "postinstall": "node scripts/bare-deps.mjs"
1111 },
1212 "dependencies": {
test/label-room-presence.test.mjs +79 -0
……@@ -0,0 +1,79 @@
1// LabelRoom on hpp's PRESENCE election (not its own Lease), with a CONTENT-keyed signed log.
2//
3// hyper-social #80 (member labels): hpp elects its signer by presence (SignerPresence) and retired the
4// append-forever __lease__ heartbeat; LabelRoom's own Lease would be a second election that can disagree.
5// And the signed log was keyed by index (pad(n)): two signers during a partition both wrote
6// __label__\x00<0> with DIFFERENT labels, last-write-wins kept one, and a label was silently lost.
7// Run: node test/label-room-presence.test.mjs
8import { mkdtempSync, rmSync } from 'node:fs'
9import { join } from 'node:path'
10import { tmpdir } from 'node:os'
11import Corestore from 'corestore'
12import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
13import { verifyLabel, decodeLabel } from 'atproto-identity/lib/labels.js'
14import { LabelRoom } from '../lib/label-room.js'
15
16const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
17function mockSb (m = new Map()) {
18 return {
19 map: m,
20 async set (k, v) { m.set(k, v) },
21 async del (k) { m.delete(k) },
22 async get (k) { return m.has(k) ? m.get(k) : null },
23 async list (prefix) { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o }
24 }
25}
26const labeler = keyFromPrivate(new Uint8Array(32).fill(44))
27const SRC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u'
28const KP = { src: SRC, privateKey: labeler.privateKey }
29const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
30const E2 = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
31
32// 1. presence-gated: only the holder signs; no lease is ever written
33{
34 const sb = mockSb()
35 const holder = new LabelRoom(sb, { ...KP, holds: () => true })
36 const other = new LabelRoom(sb, { ...KP, holds: () => false })
37 await holder.label(WJ, 'loser-club-member', { cts: '2026-10-14T00:00:00.000Z' })
38 assert(await other.tick() === 0, 'a non-holder signs nothing')
39 assert(await holder.tick() === 1, 'the holder signs the intent')
40 assert(![...sb.map.keys()].some((k) => k.startsWith('__lease__')), 'no __lease__ heartbeat is written in presence mode')
41 const log = await holder.signedLog()
42 assert(log.length === 1 && log[0].uri === WJ && verifyLabel(log[0], labeler.didKey), 'a DID-subject label, signed under the labeler key')
43}
44
45// 2. partition: two holders sign different labels into SEPARATE views; after the merge both survive
46{
47 const a = mockSb(); const b = mockSb()
48 const ra = new LabelRoom(a, { ...KP, holds: () => true })
49 const rb = new LabelRoom(b, { ...KP, holds: () => true })
50 await ra.label(WJ, 'loser-club-member', { cts: '2026-10-14T00:00:00.000Z' })
51 await rb.label(E2, 'loser-club-member', { cts: '2026-10-14T00:00:01.000Z' })
52 await ra.tick(); await rb.tick()
53 const merged = mockSb(new Map([...a.map, ...b.map])) // LWW merge, as the autobase view would converge
54 const m = new LabelRoom(merged, { ...KP, holds: () => true })
55 const log = await m.signedLog()
56 assert(log.length === 2, 'both labels survive the merge (index keys collided on __label__\\x00<0> and lost one)')
57 assert(new Set(log.map((l) => l.uri)).size === 2, 'one per member')
58 assert(await m.tick() === 0, 'and nothing is re-signed after the merge')
59}
60
61// 3. projection: labels the core does not hold are appended, even if they sort before ones it has
62{
63 const sb = mockSb()
64 const r = new LabelRoom(sb, { ...KP, holds: () => true })
65 await r.label(E2, 'loser-club-member', { cts: '2026-10-14T00:00:05.000Z' })
66 await r.tick()
67 const dir = mkdtempSync(join(tmpdir(), 'lrp-'))
68 const store = new Corestore(dir)
69 const feed = store.get({ name: 'labels', valueEncoding: 'binary' })
70 assert(await r.projectTo(feed) === 1, 'first projection appends the one label')
71 await r.label(WJ, 'loser-club-member', { cts: '2026-10-14T00:00:01.000Z' }) // sorts EARLIER by cts
72 await r.tick()
73 assert(await r.projectTo(feed) === 1, 'a label sorting earlier is still appended (index projection skipped it)')
74 assert(await r.projectTo(feed) === 0, 'idempotent')
75 const uris = []; for (let i = 0; i < feed.length; i++) uris.push(decodeLabel(await feed.get(i)).uri)
76 assert(uris.length === 2 && uris.includes(WJ) && uris.includes(E2), 'the feed holds both, each once')
77 await feed.close(); try { await store.close() } catch {}; rmSync(dir, { recursive: true, force: true })
78}
79console.log('\nLABEL-ROOM PRESENCE OK')