| … | … | @@ -19,19 +19,31 @@ import { encodeLabel, decodeLabel } from 'atproto-identity/lib/labels.js' |
| 19 | 19 | |
| 20 | 20 | const SIGNED_NS = '__label__' // reserved (leading __) — never a repo record or a label intent |
| 21 | 21 | const SEP = '\x00' |
| 22 | const pad = (n) => String(n).padStart(12, '0') // zero-pad so the shared log sorts in append order | |
| 23 | 22 | const 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. | |
| 28 | const signedKey = (l) => SIGNED_NS + SEP + String(l.cts) + SEP + l.uri + SEP + l.val + SEP + (l.neg ? '1' : '0') | |
| 24 | 29 | |
| 25 | 30 | export class LabelRoom { |
| 26 | 31 | // statebase: shared StateBase (mods = writers). src: labeler DID. privateKey: shared #atproto_label |
| 27 | 32 | // 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)') | |
| 30 | 40 | this.sb = statebase |
| 31 | 41 | this.src = src |
| 32 | 42 | this.privateKey = privateKey |
| 33 | 43 | 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 | |
| 35 | 47 | } |
| 36 | 48 | |
| 37 | 49 | // A mod records a label intent (label/negate). Authority to WRITE is StateBase admission. |
| … | … | @@ -50,28 +62,42 @@ export class LabelRoom { |
| 50 | 62 | // each participant's feed is byte-identical — so the labeler is serveable from ANY node (the |
| 51 | 63 | // serve-membrane pattern). Consumers (favela readLabelFeed) get plain signed labels. Returns the count |
| 52 | 64 | // 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. | |
| 53 | 68 | async projectTo (feedCore) { |
| 54 | 69 | 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 | } | |
| 55 | 76 | const map = await this.sb.list(SIGNED_NS + SEP) |
| 56 | const keys = Object.keys(map).sort() | |
| 57 | 77 | 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 | } | |
| 59 | 85 | return appended |
| 60 | 86 | } |
| 61 | 87 | |
| 62 | 88 | // One tick: renew/claim the lease; if we hold it, sign intents not yet in the shared __label__ log |
| 63 | 89 | // and append them. Returns the count appended (0 if not the lease-holder). Failover-safe. |
| 64 | 90 | 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 | |
| 66 | 93 | 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'))))) | |
| 70 | 95 | let count = 0 |
| 71 | 96 | for (const i of await this.base.intents()) { |
| 72 | 97 | if (emitted.has(stateOf(i))) continue |
| 73 | 98 | 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)) | |
| 75 | 101 | count++ |
| 76 | 102 | } |
| 77 | 103 | return count |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-room", |
| 3 | "version": "0.2.25", | |
| 3 | "version": "0.2.26", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "scripts": { |
| 8 | 8 | "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", | |
| 10 | 10 | "postinstall": "node scripts/bare-deps.mjs" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| … | … | @@ -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 | |
| 8 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 9 | import { join } from 'node:path' | |
| 10 | import { tmpdir } from 'node:os' | |
| 11 | import Corestore from 'corestore' | |
| 12 | import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js' | |
| 13 | import { verifyLabel, decodeLabel } from 'atproto-identity/lib/labels.js' | |
| 14 | import { LabelRoom } from '../lib/label-room.js' | |
| 15 | ||
| 16 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 17 | function 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 | } | |
| 26 | const labeler = keyFromPrivate(new Uint8Array(32).fill(44)) | |
| 27 | const SRC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u' | |
| 28 | const KP = { src: SRC, privateKey: labeler.privateKey } | |
| 29 | const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 30 | const 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 | } | |
| 79 | console.log('\nLABEL-ROOM PRESENCE OK') |