| … | … | @@ -0,0 +1,63 @@ |
| 1 | // SignerPresence — leak-free single-active-signer election from live CONNECTION PRESENCE. | |
| 2 | // | |
| 3 | // Replaces the `__lease__ {holder,epoch,ts}` heartbeat (lease.js), which wrote a fresh wall-clock ts to the | |
| 4 | // APPEND-ONLY autobase log every ~8s forever (~20MB/day, unreclaimable — see the S1 research). Keet, and | |
| 5 | // every other holepunch app, signals liveness EPHEMERALLY over the connection and never persists it. This is | |
| 6 | // that idiom: "which device signs" is a deterministic function of who is CONNECTED right now, computed in | |
| 7 | // RAM. It appends NOTHING. | |
| 8 | // | |
| 9 | // Election = the lowest-keyed device among the currently-effective co-owners (a stable, replication-free | |
| 10 | // tiebreak every connected peer computes identically → they agree on one signer → only one signs → no fork, | |
| 11 | // exactly the guarantee the ts-lease gave in the connected case). A partition still diverges and reconciles | |
| 12 | // on merge — identical to the old lease, which also forked on partition (the epoch never prevented that). | |
| 13 | // | |
| 14 | // GRACE window = blip tolerance: a departed holder stays "effective" for graceMs before failover, so a brief | |
| 15 | // disconnect does not thrash the signer (mirrors the old lease's "never steal a live lease for TTL"). Default | |
| 16 | // graceMs matches the old ttlMs (15s) so failover timing is unchanged. | |
| 17 | // | |
| 18 | // Presence is fed by the transport (a co-owner presence handshake on the StateBase swarm): markPresent on | |
| 19 | // connect+identify, markAbsent on close. This module is pure + clock-injectable → unit-testable with no swarm. | |
| 20 | ||
| 21 | export class SignerPresence { | |
| 22 | // self: this device's member key (hex). graceMs: how long a departed co-owner stays eligible (blip | |
| 23 | // tolerance / failover delay). clock: injectable now() for deterministic tests. | |
| 24 | constructor ({ self, graceMs = 15000, clock = () => Date.now() } = {}) { | |
| 25 | if (!self) throw new Error('SignerPresence: self (this device member key) required') | |
| 26 | this.self = String(self) | |
| 27 | this.graceMs = graceMs | |
| 28 | this.clock = clock | |
| 29 | this._present = new Map() // memberKey -> lastSeen: Infinity = connected now; a number = ms it departed | |
| 30 | this._present.set(this.self, Infinity) // we are always present to ourselves | |
| 31 | } | |
| 32 | ||
| 33 | // A co-owner (holds the signing key + is attested) is connected. Idempotent. | |
| 34 | markPresent (memberKey) { this._present.set(String(memberKey), Infinity) } | |
| 35 | ||
| 36 | // A co-owner disconnected — start its grace countdown (do NOT drop it immediately; a blip must not fail | |
| 37 | // the signer over). No-op for a key we never saw. | |
| 38 | markAbsent (memberKey) { | |
| 39 | const k = String(memberKey) | |
| 40 | if (this._present.has(k) && this._present.get(k) === Infinity) this._present.set(k, this.clock()) | |
| 41 | } | |
| 42 | ||
| 43 | // The co-owners eligible to sign right now: connected, or departed within graceMs. Sorted (stable pick). | |
| 44 | effective (now = this.clock()) { | |
| 45 | const out = [] | |
| 46 | for (const [k, seen] of this._present) if (seen === Infinity || (now - seen) < this.graceMs) out.push(k) | |
| 47 | return out.sort() | |
| 48 | } | |
| 49 | ||
| 50 | // The elected signer = lowest-keyed effective co-owner (every connected peer computes the same one). | |
| 51 | currentSigner (now = this.clock()) { | |
| 52 | const e = this.effective(now) | |
| 53 | return e.length ? e[0] : null | |
| 54 | } | |
| 55 | ||
| 56 | // Do WE hold the signer role right now? (Drop-in for lease.holds() / renewOrClaim() in the sign loop.) | |
| 57 | holds (now = this.clock()) { return this.currentSigner(now) === this.self } | |
| 58 | ||
| 59 | // Diagnostics snapshot (no writes). | |
| 60 | state (now = this.clock()) { | |
| 61 | return { self: this.self, signer: this.currentSigner(now), effective: this.effective(now), holds: this.holds(now) } | |
| 62 | } | |
| 63 | } |
| … | … | @@ -0,0 +1,86 @@ |
| 1 | // SignerPresence (S1) — leak-free single-signer election from connection presence. Pure logic + injectable | |
| 2 | // clock, so this runs anywhere (node/bare) with no swarm. Proves: lowest-key election, grace blip-tolerance, | |
| 3 | // cascade failover, self-election, and — structurally — that it can never write to the append-only log. | |
| 4 | // Run: `node test/signer-presence.test.mjs` | |
| 5 | import { SignerPresence } from '../lib/signer-presence.js' | |
| 6 | ||
| 7 | const assert = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m) } | |
| 8 | let NOW = 1000 | |
| 9 | const clock = () => NOW | |
| 10 | const A = 'aaaa', B = 'bbbb', C = 'cccc' | |
| 11 | ||
| 12 | // 1. lowest-key co-owner is the signer | |
| 13 | { | |
| 14 | NOW = 1000 | |
| 15 | const sp = new SignerPresence({ self: B, graceMs: 15000, clock }) | |
| 16 | sp.markPresent(A) | |
| 17 | assert(sp.currentSigner() === A, 'lowest-key co-owner (A) is elected signer') | |
| 18 | assert(sp.holds() === false, 'B (higher key) does not hold while A is present') | |
| 19 | } | |
| 20 | ||
| 21 | // 2 & 3. holder departs → grace keeps it → after grace, failover | |
| 22 | { | |
| 23 | NOW = 1000 | |
| 24 | const sp = new SignerPresence({ self: B, graceMs: 15000, clock }) | |
| 25 | sp.markPresent(A) | |
| 26 | NOW = 5000; sp.markAbsent(A) | |
| 27 | NOW = 5000 + 14999 | |
| 28 | assert(sp.currentSigner() === A, 'within grace, departed holder A stays elected (no premature failover)') | |
| 29 | assert(sp.holds() === false, 'B still does not hold within grace') | |
| 30 | NOW = 5000 + 15001 | |
| 31 | assert(sp.currentSigner() === B, 'after grace elapses, failover to B') | |
| 32 | assert(sp.holds() === true, 'B now holds') | |
| 33 | } | |
| 34 | ||
| 35 | // 4. blip (depart + return within grace) does NOT thrash the signer | |
| 36 | { | |
| 37 | NOW = 1000 | |
| 38 | const sp = new SignerPresence({ self: B, graceMs: 15000, clock }) | |
| 39 | sp.markPresent(A) | |
| 40 | NOW = 5000; sp.markAbsent(A) | |
| 41 | NOW = 12000; sp.markPresent(A) // returned within grace | |
| 42 | NOW = 5000 + 20000 // well past the original grace window | |
| 43 | assert(sp.currentSigner() === A, 'a blip (return within grace) does NOT fail the signer over') | |
| 44 | assert(sp.holds() === false, 'B never took over on the blip') | |
| 45 | } | |
| 46 | ||
| 47 | // 5. self is lowest → self holds | |
| 48 | { | |
| 49 | NOW = 1000 | |
| 50 | const sp = new SignerPresence({ self: A, graceMs: 15000, clock }) | |
| 51 | sp.markPresent(B) | |
| 52 | assert(sp.holds() === true, 'self=A (lowest) holds') | |
| 53 | } | |
| 54 | ||
| 55 | // 6. all lower co-owners gone + grace → self holds | |
| 56 | { | |
| 57 | NOW = 1000 | |
| 58 | const sp = new SignerPresence({ self: B, graceMs: 15000, clock }) | |
| 59 | sp.markPresent(A) | |
| 60 | NOW = 2000; sp.markAbsent(A) | |
| 61 | NOW = 2000 + 15001 | |
| 62 | assert(sp.holds() === true, 'after the only lower co-owner departs+grace, self holds') | |
| 63 | assert(sp.currentSigner() === B, 'self is the sole effective signer') | |
| 64 | } | |
| 65 | ||
| 66 | // 7. three co-owners: cascade failover A→B→C | |
| 67 | { | |
| 68 | NOW = 1000 | |
| 69 | const sp = new SignerPresence({ self: C, graceMs: 10000, clock }) | |
| 70 | sp.markPresent(A); sp.markPresent(B) | |
| 71 | assert(sp.currentSigner() === A, 'A (lowest of three) signs') | |
| 72 | NOW = 2000; sp.markAbsent(A); NOW = 2000 + 10001 | |
| 73 | assert(sp.currentSigner() === B, 'A gone+grace → B signs') | |
| 74 | assert(sp.holds() === false, 'C still not holding while B present') | |
| 75 | NOW = 30000; sp.markAbsent(B); NOW = 30000 + 10001 | |
| 76 | assert(sp.holds() === true, 'B gone+grace → C (self) holds') | |
| 77 | } | |
| 78 | ||
| 79 | // 8. structurally leak-free: no statebase, no append path | |
| 80 | { | |
| 81 | const sp = new SignerPresence({ self: A }) | |
| 82 | assert(sp.sb === undefined && typeof sp.set !== 'function' && typeof sp.append !== 'function', | |
| 83 | 'SignerPresence has NO statebase/append path — zero log writes by construction') | |
| 84 | } | |
| 85 | ||
| 86 | console.log('\nSIGNER-PRESENCE OK: lowest-key election, grace blip-tolerance, cascade failover, zero log writes') |