| … | … | @@ -24,6 +24,7 @@ import { SwarmDevice } from './swarm-device.js' |
| 24 | 24 | import { nextRetry } from './reconcile-retry.js' |
| 25 | 25 | import { serveDevice } from './serve-device.js' |
| 26 | 26 | import { serveDeviceUds } from './serve-device-uds.js' |
| 27 | import { LabelRoom } from 'atproto-room/lib/label-room.js' | |
| 27 | 28 | import { serveMembrane } from 'atproto-room/lib/serve-membrane.js' |
| 28 | 29 | import { BlobStore } from 'atproto-store/lib/blob-store.js' |
| 29 | 30 | import { OverlayBlobStore } from 'atproto-store/lib/overlay-blob-store.js' |
| … | … | @@ -112,7 +113,7 @@ export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemoni |
| 112 | 113 | // So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner |
| 113 | 114 | // sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had |
| 114 | 115 | // to solve exactly this for a creator rejoining from a fresh store. |
| 115 | export 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 } = {}) { | |
| 116 | export 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 } = {}) { | |
| 116 | 117 | if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required') |
| 117 | 118 | if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required") |
| 118 | 119 | 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 |
| 240 | 241 | console.log(JSON.stringify({ msg: 'record store: overlay (content-addressed files)', did, path: repoRoot })) |
| 241 | 242 | } |
| 242 | 243 | 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 | |
| 243 | 250 | // NO unconditional boot claim: that let a booting device STEAL a still-valid lease before it had |
| 244 | 251 | // replicated the current holder's lease (the split-brain root cause). The sign loop claims lazily |
| 245 | 252 | // + 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 |
| 478 | 485 | if (sb.base && !sb.base.writable) { try { await sb.ensureAdmitted() } catch {} } |
| 479 | 486 | reconcile() // deliberately NOT awaited — byte convergence must not gate signing (or vice versa) |
| 480 | 487 | await device.sign() |
| 481 | if (membraneServing) await membraneServing.sync() | |
| 488 | if (labelRoom) await labelRoom.tick() | |
| 489 | if (membraneServing) { await membraneServing.sync(); await membraneServing.syncLabels() } | |
| 482 | 490 | } catch {} |
| 483 | 491 | finally { ticking = false; if (tickAgain) { tickAgain = false; tick() } } |
| 484 | 492 | } |
| … | … | @@ -582,6 +590,7 @@ export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKe |
| 582 | 590 | udsServing = await serveDeviceUds(device, { |
| 583 | 591 | uds: join(ctlDir, 'control.sock'), |
| 584 | 592 | blobs, |
| 593 | labels: labelRoom, | |
| 585 | 594 | // EXPLICIT handler first. The module-global `otaHandler` below is a fallback kept for callers |
| 586 | 595 | // that still use setOtaHandler(), but it is NOT the primary path any more: a module-level |
| 587 | 596 | // 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 |
| 634 | 643 | // via the get-repo-car RPC (proven byte-identical in test/derive-vs-project). Defaults TRUE so |
| 635 | 644 | // nothing changes until a DID is explicitly flipped — rolled out one DID and one node at a time. |
| 636 | 645 | // 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 }) | |
| 638 | 647 | await awaitOrContinue('the membrane DHT announce', membraneServing.ready()) |
| 639 | 648 | // sync() reads the device's __commit__ log through the StateBase. On a device that is not yet admitted |
| 640 | 649 | // (roster projected by another machine — #40 §6) that base may never advance, so this too is bounded. |
| … | … | @@ -11,7 +11,7 @@ import fs from 'bare-fs' |
| 11 | 11 | import { Service } from 'hyper-svc' |
| 12 | 12 | import { conflictDiag } from './conflict-watch.js' |
| 13 | 13 | |
| 14 | export async function serveDeviceUds (device, { uds, blobs = null, onRelease = null } = {}) { | |
| 14 | export async function serveDeviceUds (device, { uds, blobs = null, onRelease = null, labels = null } = {}) { | |
| 15 | 15 | if (!uds) throw new Error('serveDeviceUds: uds path required') |
| 16 | 16 | const svc = new Service({ name: 'hyper-proto-peer', uds }) |
| 17 | 17 | |
| … | … | @@ -65,6 +65,22 @@ export async function serveDeviceUds (device, { uds, blobs = null, onRelease = n |
| 65 | 65 | // into a fresh line). Public (UDS is filesystem-owner-gated). Bytes as hex, matching get-blob. |
| 66 | 66 | 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 } }) |
| 67 | 67 | 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 | }) | |
| 68 | 84 | // get-record: read ONE record back. get-state lists {collection, rkey} and no values, so a client |
| 69 | 85 | // could see THAT a record exists and never what it says — which makes "is what I would write |
| 70 | 86 | // already there?" unanswerable, and the only alternative is a local fingerprint that drifts from |
| … | … | @@ -358,6 +358,16 @@ export class SwarmDevice { |
| 358 | 358 | return tidNow() |
| 359 | 359 | } |
| 360 | 360 | |
| 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 | ||
| 361 | 371 | // Materialize the current linearized view into the NEXT canonical commit — only if we hold the |
| 362 | 372 | // lease (or force, for a bootstrap/test). Continue-from-head: prev = last commit CID, rev |
| 363 | 373 | // monotonic. Returns the new head, or null if we don't hold the lease or the view is unchanged. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.127", | |
| 3 | "version": "0.3.128", | |
| 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", |
| … | … | @@ -25,6 +25,9 @@ import './pointer-substrate.test.mjs' |
| 25 | 25 | import './multi-did-daemon.test.js' |
| 26 | 26 | import './multi-did-2node.test.js' |
| 27 | 27 | import './multi-did-signer.test.js' |
| 28 | import './swarm-device-is-signer.test.mjs' | |
| 29 | import './label-uds.test.mjs' | |
| 30 | import './labeler-did.test.js' // labeler DID end to end (member labels, hyper-social #80) | |
| 28 | 31 | import './loser-club-cutover.test.js' |
| 29 | 32 | import './delegate.test.js' |
| 30 | 33 | import './serve-device.test.js' |
| … | … | @@ -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 | |
| 9 | import '../lib/bare-globals.js' | |
| 10 | import test from 'brittle' | |
| 11 | import fs from 'bare-fs' | |
| 12 | import { join } from 'bare-path' | |
| 13 | import { Client } from 'hyper-svc' | |
| 14 | import { serveDeviceUds } from '../lib/serve-device-uds.js' | |
| 15 | import { LabelRoom } from 'atproto-room/lib/label-room.js' | |
| 16 | ||
| 17 | const SRC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u' | |
| 18 | const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo' | |
| 19 | const KEY = new Uint8Array(32).fill(44) | |
| 20 | const tmp = () => { const d = '/tmp/hpp-lu-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 21 | const device = { state: () => ({ ok: true }), membersDiag: async () => ({}), did: SRC } | |
| 22 | ||
| 23 | function 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 | ||
| 35 | async 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 | ||
| 43 | test('★ 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 | ||
| 65 | test('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 | ||
| 70 | test('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 | }) |
| … | … | @@ -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 | |
| 9 | import '../lib/bare-globals.js' | |
| 10 | import test from 'brittle' | |
| 11 | import fs from 'bare-fs' | |
| 12 | import b4a from 'b4a' | |
| 13 | import createTestnet from '@hyperswarm/testnet' | |
| 14 | import { Client } from 'hyper-svc' | |
| 15 | import { bootMultiDevice, provisionDevice } from '../lib/device-node.js' | |
| 16 | import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js' | |
| 17 | import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js' | |
| 18 | import { decodeLabel, verifyLabel } from 'atproto-identity/lib/labels.js' | |
| 19 | ||
| 20 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 21 | const DEV = 'legal winner thank year wave sausage worth useful legal winner thank yellow' | |
| 22 | const tmp = () => { const d = '/tmp/hpp-lab-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } | |
| 23 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) | |
| 24 | const 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 | ||
| 26 | async 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 | ||
| 38 | test('★ 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 | ||
| 64 | test('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 | }) |
| … | … | @@ -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 | |
| 10 | import '../lib/bare-globals.js' | |
| 11 | import test from 'brittle' | |
| 12 | import b4a from 'b4a' | |
| 13 | import { SwarmDevice } from '../lib/swarm-device.js' | |
| 14 | ||
| 15 | const DID = 'did:plc:issigner' | |
| 16 | const KEY = (() => { const k = b4a.alloc(32); k[31] = 9; return k })() | |
| 17 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-10-13T00:00:00.000Z' }) | |
| 18 | ||
| 19 | class 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 | ||
| 29 | test('★ 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 | ||
| 44 | test('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 | ||
| 49 | test('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 | }) |