OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / a6abb81

0.3.128: labeler DIDs — sign labels with the DID key, serve them on the membrane
Member labels, hyper-social #80 step 2. `labeler: true` on a DID's config entry hosts a LabelRoom on that DID's StateBase: - signed with the DID key, gated by device.isSigner() — the same presence election, with the same network-head rev, that sign() uses, so labels and commits always have one signer; - intents under the reserved __labelintent__ name (atproto-room 0.2.29): this base holds the repo, and an intent on a post URI would otherwise list as a record; - the event-driven tick runs labelRoom.tick() after sign() and syncLabels() after the membrane sync; serveMembrane serves membrane-labels:<did> and announces labelKey/labelLength (atproto-room 0.2.28). UDS verbs: label-intent {uri, val, neg} (idempotent: an unchanged state writes nothing, LabelBase.ensure) and label-list. A non-labeler DID answers "not a labeler". Tests: swarm-device-is-signer, label-uds, labeler-did (testnet: intent → verified label in the served core, nothing in the repo record set). Related suites green: forward-only-sign, multi-did-signer, signer-failover, diag, ota-respawn-wiring, upload-blob-path-uds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a6abb810678607e167374d5b415e99e216a04415
by plankatron <fleeky@gmail.com> · 2026-10-14T02:06:46.000Z

Changed files (8) +241 -5

lib/device-node.js +12 -3
……@@ -24,6 +24,7 @@ import { SwarmDevice } from './swarm-device.js'
2424import { nextRetry } from './reconcile-retry.js'
2525import { serveDevice } from './serve-device.js'
2626import { serveDeviceUds } from './serve-device-uds.js'
27import { LabelRoom } from 'atproto-room/lib/label-room.js'
2728import { serveMembrane } from 'atproto-room/lib/serve-membrane.js'
2829import { BlobStore } from 'atproto-store/lib/blob-store.js'
2930import { OverlayBlobStore } from 'atproto-store/lib/overlay-blob-store.js'
……@@ -112,7 +113,7 @@ export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemoni
112113// So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner
113114// sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had
114115// to solve exactly this for a creator rejoining from a fresh store.
115export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true, onRelease = null } = {}) {
116export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true, onRelease = null, labeler = false } = {}) {
116117 if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required')
117118 if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required")
118119 if (!did || !signingKey || !deviceProof || !rootIdentityKey) throw new Error('bootSwarmDevice: provisioned bundle (did, signingKey, deviceProof, rootIdentityKey) required — run provisionDevice on the root')
……@@ -240,6 +241,12 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
240241 console.log(JSON.stringify({ msg: 'record store: overlay (content-addressed files)', did, path: repoRoot }))
241242 }
242243 const device = new SwarmDevice(sb, { did, privateKey: sharedKey, self, firehose: true, recordStore })
244 // LABELER DID (`labeler: true` on its config entry; member labels, hyper-social #80). Moderators' label
245 // intents and the signed label log live in this DID's own StateBase, signed with the DID key — so the
246 // co-owners that sign its commits are exactly the ones that can sign its labels, with no second
247 // election: `holds` is the repo signer's own gate (device.isSigner). Intents sit under a RESERVED name
248 // because this base also holds the repo, and RepoBase reads any "/"-bearing non-"__" key as a record.
249 const labelRoom = labeler ? new LabelRoom(sb, { src: did, privateKey: sharedKey, holds: () => device.isSigner(), ns: '__labelintent__' }) : null
243250 // NO unconditional boot claim: that let a booting device STEAL a still-valid lease before it had
244251 // replicated the current holder's lease (the split-brain root cause). The sign loop claims lazily
245252 // + safely via renewOrClaim (claim ONLY if the lease is free/TTL-lapsed), after replication has had
……@@ -478,7 +485,8 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
478485 if (sb.base && !sb.base.writable) { try { await sb.ensureAdmitted() } catch {} }
479486 reconcile() // deliberately NOT awaited — byte convergence must not gate signing (or vice versa)
480487 await device.sign()
481 if (membraneServing) await membraneServing.sync()
488 if (labelRoom) await labelRoom.tick()
489 if (membraneServing) { await membraneServing.sync(); await membraneServing.syncLabels() }
482490 } catch {}
483491 finally { ticking = false; if (tickAgain) { tickAgain = false; tick() } }
484492 }
……@@ -582,6 +590,7 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
582590 udsServing = await serveDeviceUds(device, {
583591 uds: join(ctlDir, 'control.sock'),
584592 blobs,
593 labels: labelRoom,
585594 // EXPLICIT handler first. The module-global `otaHandler` below is a fallback kept for callers
586595 // that still use setOtaHandler(), but it is NOT the primary path any more: a module-level
587596 // variable is only shared if index.js and this file resolve to the SAME module instance, and
……@@ -634,7 +643,7 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe
634643 // via the get-repo-car RPC (proven byte-identical in test/derive-vs-project). Defaults TRUE so
635644 // nothing changes until a DID is explicitly flipped — rolled out one DID and one node at a time.
636645 // Flipping back is instant: the existing core on disk is untouched, just not opened.
637 membraneServing = await serveMembrane(device, { corestore: membraneStore, swarm: controlSwarm, did, handle, sessions, blobs, projectRepoCore })
646 membraneServing = await serveMembrane(device, { corestore: membraneStore, swarm: controlSwarm, did, handle, sessions, blobs, projectRepoCore, labels: labelRoom })
638647 await awaitOrContinue('the membrane DHT announce', membraneServing.ready())
639648 // sync() reads the device's __commit__ log through the StateBase. On a device that is not yet admitted
640649 // (roster projected by another machine — #40 §6) that base may never advance, so this too is bounded.
lib/serve-device-uds.js +17 -1
……@@ -11,7 +11,7 @@ import fs from 'bare-fs'
1111import { Service } from 'hyper-svc'
1212import { conflictDiag } from './conflict-watch.js'
1313
14export async function serveDeviceUds (device, { uds, blobs = null, onRelease = null } = {}) {
14export async function serveDeviceUds (device, { uds, blobs = null, onRelease = null, labels = null } = {}) {
1515 if (!uds) throw new Error('serveDeviceUds: uds path required')
1616 const svc = new Service({ name: 'hyper-proto-peer', uds })
1717
……@@ -65,6 +65,22 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n
6565 // into a fresh line). Public (UDS is filesystem-owner-gated). Bytes as hex, matching get-blob.
6666 svc.handle('get-repo-car', async () => { const r = await device.exportCar(); return { car: b4a.toString(r.carBytes, 'hex'), commitCid: r.commitCid, dataCid: r.dataCid, rev: r.rev, recordCount: r.recordCount } })
6767 svc.handle('apply-write', async (ctx, op) => device.applyWrite(op))
68 // label-intent / label-list — a LABELER DID's moderation surface (member labels, hyper-social #80).
69 // `labels` is the DID's LabelRoom. A projector (the gateway's roster → member badges) re-asserts its whole
70 // set on every change, so label-intent is IDEMPOTENT: an unchanged state writes nothing and keeps its
71 // cts (LabelRoom.ensure). The write lands in the StateBase, whose watch fires the tick that signs it —
72 // nothing to trigger here. Registered on every DID so a non-labeler answers "not a labeler", not
73 // "unknown method".
74 const needLabels = () => { if (!labels) throw new Error('this DID is not a labeler (set labeler: true in its config)'); return labels }
75 svc.handle('label-intent', async (ctx, { uri, val, neg = false } = {}) => {
76 const l = needLabels()
77 if (!uri || !val) throw new Error('label-intent: uri and val required')
78 return l.ensure(uri, val, { neg: !!neg })
79 })
80 svc.handle('label-list', async () => {
81 const l = needLabels()
82 return { intents: await l.base.intents(), signed: (await l.signedLog()).length }
83 })
6884 // get-record: read ONE record back. get-state lists {collection, rkey} and no values, so a client
6985 // could see THAT a record exists and never what it says — which makes "is what I would write
7086 // already there?" unanswerable, and the only alternative is a local fingerprint that drifts from
lib/swarm-device.js +10 -0
……@@ -358,6 +358,16 @@ export class SwarmDevice {
358358 return tidNow()
359359 }
360360
361 // Would sign() pass its election gate right now? For a CO-SIGNER of this DID's key (the labeler's
362 // LabelRoom): it must sign on exactly the node that signs commits, so it asks the same election with the
363 // same network-head rev — two elections that can disagree is how two nodes end up signing. Read-only.
364 async isSigner () {
365 if (!this.sb.holdsSigner) return true // no election (single writer / unit mock): sign()'s default
366 let net = null
367 if (this.firehose) net = commitLine(Object.values(await this.sb.listNow(FIREHOSE_PREFIX)).filter((f) => f && f.rev)).head
368 return !!this.sb.holdsSigner(undefined, net ? net.rev : null)
369 }
370
361371 // Materialize the current linearized view into the NEXT canonical commit — only if we hold the
362372 // lease (or force, for a bootstrap/test). Continue-from-head: prev = last commit CID, rev
363373 // monotonic. Returns the new head, or null if we don't hold the lease or the view is unchanged.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.127",
3 "version": "0.3.128",
44 "description": "Self-custody, P2P-native atproto peer \u2014 holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +3 -0
……@@ -25,6 +25,9 @@ import './pointer-substrate.test.mjs'
2525import './multi-did-daemon.test.js'
2626import './multi-did-2node.test.js'
2727import './multi-did-signer.test.js'
28import './swarm-device-is-signer.test.mjs'
29import './label-uds.test.mjs'
30import './labeler-did.test.js' // labeler DID end to end (member labels, hyper-social #80)
2831import './loser-club-cutover.test.js'
2932import './delegate.test.js'
3033import './serve-device.test.js'
test/label-uds.test.mjs +74 -0
……@@ -0,0 +1,74 @@
1// label-intent / label-list — the local surface a projector uses to label (member labels, hyper-social #80).
2//
3// The gateway's member projector calls label-intent for each roster member (and with neg:true when one
4// leaves) on the labeler DID's local control socket. It re-asserts the whole set on every roster change,
5// so the verb must be idempotent: an unchanged state writes nothing (LabelRoom.ensure). A DID that is not
6// a labeler answers with an error that says so, not an unknown-method error.
7//
8// Run: bare test/label-uds.test.mjs
9import '../lib/bare-globals.js'
10import test from 'brittle'
11import fs from 'bare-fs'
12import { join } from 'bare-path'
13import { Client } from 'hyper-svc'
14import { serveDeviceUds } from '../lib/serve-device-uds.js'
15import { LabelRoom } from 'atproto-room/lib/label-room.js'
16
17const SRC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u'
18const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
19const KEY = new Uint8Array(32).fill(44)
20const tmp = () => { const d = '/tmp/hpp-lu-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
21const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: SRC }
22
23function mockSb () {
24 const m = new Map()
25 let writes = 0
26 return {
27 writes: () => writes,
28 async set (k, v) { writes++; 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
35async function serve (t, labels) {
36 const uds = join(tmp(), 'c.sock')
37 const s = await serveDeviceUds(device, { uds, labels })
38 const c = await Client.connectUds(uds)
39 t.teardown(async () => { try { await c.close() } catch {} ; try { await s.close() } catch {} })
40 return c
41}
42
43test('★ label-intent is idempotent; label-list shows intents and how many are signed', async (t) => {
44 const sb = mockSb()
45 const room = new LabelRoom(sb, { src: SRC, privateKey: KEY, holds: () => true, ns: '__labelintent__' })
46 const c = await serve(t, room)
47
48 const a = await c.call('label-intent', { uri: WJ, val: 'loser-club-member' })
49 t.is(a.changed, true, 'a new label is written')
50 const n = sb.writes()
51 const b = await c.call('label-intent', { uri: WJ, val: 'loser-club-member' })
52 t.is(b.changed, false, 'the same label again is not')
53 t.is(sb.writes(), n, 'and writes nothing')
54
55 await room.tick()
56 const l = await c.call('label-list', {})
57 t.is(l.intents.length, 1)
58 t.alike({ uri: l.intents[0].uri, val: l.intents[0].val, neg: l.intents[0].neg }, { uri: WJ, val: 'loser-club-member', neg: false })
59 t.is(l.signed, 1, 'one label in the signed log')
60
61 const g = await c.call('label-intent', { uri: WJ, val: 'loser-club-member', neg: true })
62 t.is(g.changed, true, 'a negation is a change')
63})
64
65test('label-intent checks its input', async (t) => {
66 const c = await serve(t, new LabelRoom(mockSb(), { src: SRC, privateKey: KEY, holds: () => true, ns: '__labelintent__' }))
67 await t.exception(() => c.call('label-intent', { uri: WJ }), /val/)
68})
69
70test('a DID that is not a labeler says so', async (t) => {
71 const c = await serve(t, null)
72 await t.exception(() => c.call('label-intent', { uri: WJ, val: 'x' }), /not a labeler/)
73 await t.exception(() => c.call('label-list', {}), /not a labeler/)
74})
test/labeler-did.test.js +68 -0
……@@ -0,0 +1,68 @@
1// A LABELER DID end to end in the daemon (member labels, hyper-social #80 step 2).
2//
3// `labeler: true` on a DID's config entry hosts a LabelRoom on that DID's StateBase, signed with the DID
4// key on the node the repo signer election picks. A label-intent on the local socket must come out as a
5// verified label in the membrane's served label core (named in the announce) — and must NOT enter the
6// signed repo: an intent on a post URI has a "/" and would otherwise list as a record.
7//
8// Run: bare test/labeler-did.test.js
9import '../lib/bare-globals.js'
10import test from 'brittle'
11import fs from 'bare-fs'
12import b4a from 'b4a'
13import createTestnet from '@hyperswarm/testnet'
14import { Client } from 'hyper-svc'
15import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
16import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
17import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
18import { decodeLabel, verifyLabel } from 'atproto-identity/lib/labels.js'
19
20const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
21const DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
22const tmp = () => { const d = '/tmp/hpp-lab-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
23const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
24const waitFor = async (fn, timeout = 30000) => { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(200) } return false }
25
26async function boot (t, { labeler }) {
27 const testnet = await createTestnet(3)
28 const dk = await deviceKeetKeyPair(DEV)
29 const b = await provisionDevice({ rootMnemonic: ROOT, devicePublicKey: dk.publicKey, handle: 'lc.local', pdsUrl: 'https://pds.local' })
30 const entry = { deviceMnemonic: DEV, did: b.did, signingKey: b.signingKey, deviceProof: b.deviceProof, rootIdentityKey: b.rootIdentityKey, handle: 'lc.local', labeler }
31 const n = await bootMultiDevice({ storage: tmp(), dids: [entry], swarmOpts: { bootstrap: testnet.bootstrap } })
32 const node = n.byDid.get(b.did)
33 const c = await Client.connectUds(node.uds)
34 t.teardown(async () => { try { await c.close() } catch {} ; try { await n.close() } catch {} ; await testnet.destroy() })
35 return { node, c, did: b.did, signingKey: b.signingKey }
36}
37
38test('★ labeler DID: label-intent → a verified label in the served label core, never a repo record', { timeout: 120000 }, async (t) => {
39 const { node, c, did, signingKey } = await boot(t, { labeler: true })
40 const POST = 'at://did:plc:wj62ebbusln6gxo5azo6ogzo/app.bsky.feed.post/3mrhxnkbhv622'
41 const MEMBER = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
42
43 t.ok(node.membrane.labelKey, 'the membrane serves a label core')
44 t.is(node.membrane.announcement().labelKey, node.membrane.labelKey, 'named in the announce')
45
46 t.is((await c.call('label-intent', { uri: MEMBER, val: 'loser-club-member' })).changed, true)
47 t.is((await c.call('label-intent', { uri: POST, val: 'spam' })).changed, true)
48
49 const served = await waitFor(() => node.membrane.labelCore.length === 2)
50 t.ok(served, 'both labels signed and projected by the event-driven tick (no manual call)')
51
52 const pub = keyFromPrivate(b4a.from(signingKey, 'hex')).didKey
53 for (let i = 0; i < node.membrane.labelCore.length; i++) {
54 const l = decodeLabel(await node.membrane.labelCore.get(i))
55 t.is(l.src, did, 'src is the labeler DID')
56 t.ok(verifyLabel(l, pub), 'signed with the DID key: ' + l.val)
57 }
58
59 const recs = await node.device.repo.listRecords()
60 t.ok(!recs.some((r) => r.collection.includes('label')), 'no label intent entered the repo record set')
61 t.is((await c.call('label-list', {})).signed, 2)
62})
63
64test('a DID without labeler: no label core, verbs say so', { timeout: 120000 }, async (t) => {
65 const { node, c } = await boot(t, { labeler: false })
66 t.is(node.membrane.labelKey, null)
67 await t.exception(() => c.call('label-list', {}), /not a labeler/)
68})
test/swarm-device-is-signer.test.mjs +56 -0
……@@ -0,0 +1,56 @@
1// SwarmDevice.isSigner — the signer election as sign() runs it, for a co-signer that is not sign().
2//
3// A labeler DID's LabelRoom signs labels with the same DID key the repo is signed with (member labels,
4// hyper-social #80). It must sign on exactly the node that signs commits: two elections that can
5// disagree is how two nodes end up signing. sign() gates on `sb.holdsSigner(undefined, netRev)` where
6// netRev is the network head's rev (currency-aware: a co-owner behind the head must not hold). isSigner
7// asks the same question with the same netRev.
8//
9// Run: bare test/swarm-device-is-signer.test.mjs
10import '../lib/bare-globals.js'
11import test from 'brittle'
12import b4a from 'b4a'
13import { SwarmDevice } from '../lib/swarm-device.js'
14
15const DID = 'did:plc:issigner'
16const KEY = (() => { const k = b4a.alloc(32); k[31] = 9; return k })()
17const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-10-13T00:00:00.000Z' })
18
19class MemKV {
20 constructor () { this.m = new Map() }
21 async set (k, v) { this.m.set(k, v) }
22 async get (k) { return this.m.has(k) ? this.m.get(k) : null }
23 async getNow (k) { return this.get(k) }
24 async del (k) { this.m.delete(k) }
25 async list (prefix = '') { const o = {}; for (const [k, v] of this.m) if (k.startsWith(prefix)) o[k] = v; return o }
26 async listNow (prefix = '') { return this.list(prefix) }
27}
28
29test('★ isSigner asks the presence election with the NETWORK head rev, as sign() does', async (t) => {
30 const sb = new MemKV()
31 const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
32 await A.putRecord('app.bsky.feed.post', 'r1', post('one'))
33 const h = await A.sign({ force: true })
34
35 const asked = []
36 let answer = false
37 sb.holdsSigner = (now, netHead) => { asked.push(netHead); return answer }
38 t.is(await A.isSigner(), false, 'the election says no → no')
39 answer = true
40 t.is(await A.isSigner(), true, 'the election says yes → yes')
41 t.alike(asked, [h.rev, h.rev], 'each time asked with the network head rev')
42})
43
44test('a substrate with no election (single writer) signs — the same default as sign()', async (t) => {
45 const A = new SwarmDevice(new MemKV(), { did: DID, privateKey: KEY, self: 'A', firehose: true })
46 t.is(await A.isSigner(), true)
47})
48
49test('before any commit the net head is null', async (t) => {
50 const sb = new MemKV()
51 const asked = []
52 sb.holdsSigner = (now, netHead) => { asked.push(netHead); return true }
53 const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true })
54 await A.isSigner()
55 t.alike(asked, [null])
56})