| … | … | @@ -90,6 +90,9 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n |
| 90 | 90 | intents: await l.base.intents(), |
| 91 | 91 | signed: log.length, |
| 92 | 92 | holds, |
| 93 | // Did the signed log reach the label core the membrane serves? (LabelRoom.lastProjection — null until the | |
| 94 | // first projectTo since boot.) | |
| 95 | projection: l.lastProjection === undefined ? null : l.lastProjection, | |
| 93 | 96 | labels: log.map((x) => ({ uri: x.uri, val: x.val, cts: x.cts, neg: !!x.neg, ver: x.ver === undefined ? null : x.ver })) |
| 94 | 97 | } |
| 95 | 98 | }) |
| … | … | @@ -850,6 +850,18 @@ export class SwarmDevice { |
| 850 | 850 | // The field that ACTUALLY gates signing: the presence election. Null when the substrate has no |
| 851 | 851 | // presence (unit mocks / single-writer), where sign() defaults to signing. |
| 852 | 852 | holdsSigner: (() => { try { return this.sb.holdsSigner ? !!this.sb.holdsSigner() : null } catch { return null } })(), |
| 853 | // ⛔ THE GATE'S VIEW (2026-10-14: both loser-club co-owners said holdsSigner:true). holdsSigner above asks | |
| 854 | // WITHOUT the network head; sign() and the label tick ask WITH it (isSigner). Report both, with who is | |
| 855 | // present and which heads are known, so a split election is a read, not an inference. Non-blocking (listNow). | |
| 856 | isSigner: await this.isSigner().catch(() => null), | |
| 857 | election: await (async () => { | |
| 858 | if (!this.sb.signerState) return null | |
| 859 | try { | |
| 860 | let netRev = null | |
| 861 | if (this.firehose) { const h = commitLine(Object.values(await this.sb.listNow(FIREHOSE_PREFIX)).filter((f) => f && f.rev)).head; netRev = h ? h.rev : null } | |
| 862 | return { netRev, withHead: this.sb.signerState(undefined, netRev), noHead: this.sb.signerState() } | |
| 863 | } catch { return null } | |
| 864 | })(), | |
| 853 | 865 | indexers: (this.sb.base && this.sb.base.linearizer && this.sb.base.linearizer.indexers) ? this.sb.base.linearizer.indexers.length : null, |
| 854 | 866 | writable: !!(this.sb.base && this.sb.base.writable), |
| 855 | 867 | head: this.head ? { rev: this.head.rev, commitCid: this.head.commitCid.toString() } : null, |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.131", | |
| 3 | "version": "0.3.132", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/peer.js", |
| … | … | @@ -78,6 +78,7 @@ test('label-list reports the label gate and each signed label with its ver', asy |
| 78 | 78 | t.is(l.labels.length, 1) |
| 79 | 79 | t.alike({ uri: l.labels[0].uri, val: l.labels[0].val, ver: l.labels[0].ver, neg: l.labels[0].neg }, { uri: WJ, val: 'loser-club-member', ver: 1, neg: false }) |
| 80 | 80 | t.ok(typeof l.labels[0].cts === 'string', 'with its cts') |
| 81 | t.is(l.projection, null, 'projection: null until the label room has projected into a core') | |
| 81 | 82 | gate = false |
| 82 | 83 | t.is((await c.call('label-list', {})).holds, false, 'and it follows the election') |
| 83 | 84 | }) |
| … | … | @@ -41,6 +41,24 @@ test('★ isSigner asks the presence election with the NETWORK head rev, as sign |
| 41 | 41 | t.alike(asked, [h.rev, h.rev], 'each time asked with the network head rev') |
| 42 | 42 | }) |
| 43 | 43 | |
| 44 | // ⛔ THE ELECTION MUST BE READABLE AS THE SIGN GATE SEES IT. 2026-10-14: loser-club's two co-owners BOTH reported | |
| 45 | // holdsSigner:true while replicating the same base; get-state asked the election without the network head, the | |
| 46 | // gate asks with it, and neither showed who was present or which heads were known. state() now reports both. | |
| 47 | test('state() reports the election with and without the network head, and isSigner', async (t) => { | |
| 48 | const sb = new MemKV() | |
| 49 | const A = new SwarmDevice(sb, { did: DID, privateKey: KEY, self: 'A', firehose: true }) | |
| 50 | await A.putRecord('app.bsky.feed.post', 'r1', post('one')) | |
| 51 | const h = await A.sign({ force: true }) | |
| 52 | sb.holdsSigner = (now, netHead) => netHead === null || netHead === undefined | |
| 53 | sb.signerState = (now, netHead) => ({ self: 'A', signer: netHead ? 'B' : 'A', effective: ['A', 'B'], holds: !netHead, heads: { A: null, B: h.rev } }) | |
| 54 | const s = await A.state() | |
| 55 | t.is(s.isSigner, false, 'isSigner: the gate (asked with the network head)') | |
| 56 | t.is(s.election.netRev, h.rev, 'the network head rev the gate uses') | |
| 57 | t.is(s.election.withHead.signer, 'B') | |
| 58 | t.is(s.election.noHead.signer, 'A') | |
| 59 | t.alike(s.election.withHead.heads, { A: null, B: h.rev }, 'with each co-owner\'s known head') | |
| 60 | }) | |
| 61 | ||
| 44 | 62 | test('a substrate with no election (single writer) signs — the same default as sign()', async (t) => { |
| 45 | 63 | const A = new SwarmDevice(new MemKV(), { did: DID, privateKey: KEY, self: 'A', firehose: true }) |
| 46 | 64 | t.is(await A.isSigner(), true) |