/**
* state-base-v3.js — Per-room game-state store, v3 (HyperDB view).
*
* Clean cutover from the v2 Hyperbee StateBase (state-base.js, kept as reference).
* Storage layer is keet-faithful: the Autobase view is a HyperDB instance, ops
* are hyperdispatch-encoded and routed through a generated Router to handlers
* that write into HyperDB collections inside a per-apply transaction. See
* spec/state-base-design.md + spec/state-base-v3.md.
*
* Bootstrap/keys/encryption are UNCHANGED from v2 (room-state.md v2): base.key is
* derived deterministically from the room's members-only encryptionKey, opened
* encrypted; non-members can neither derive nor read it.
*
* v3.0a (this file): single-writer (deterministic creator) round-trip + lifecycle
* (fd-lock-safe open via the router cache, corruption quarantine, encryption).
* v3.0b (next): multi-writer admission (members projection + optimistic _admit +
* addWriter), ported from v2 §3.2 to the hyperdispatch op model.
*/
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hypercore from 'hypercore'
import Verifier from 'hypercore/lib/verifier' // encodeManifest/decodeManifest for the keet-writer admission proof (A)
import Hyperswarm from 'hyperswarm'
import HyperDB from 'hyperdb'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import path from 'bare-path'
import process from 'bare-process'
import hyperdbDefinition from '../spec/state-base/contract/hyperdb/index.js'
import dispatch from '../spec/state-base/contract/hyperdispatch/index.js'
import { timed } from './timed.js'
const { Router, encode } = dispatch
const STATE_VERSION = 3
const GS_NAMESPACE = Buffer.from('hyper-tps/state/v' + STATE_VERSION, 'utf-8')
const ENTRIES = '@state/entries'
// Reserved op key: a designated admin anchor claims the SOLE indexer slot
// (move-indexer). Carried as the `key` of an optimistic admit op; never inserted.
const MOVE_INDEXER = '__move_indexer__'
// Reserved op key: an admin GROWS the indexer set by adding ITSELF as an indexer
// (keet members/index.js op D `start-indexing`) — NO demote of anyone. This is the
// quorum-resilience path: with >=3 admin-indexers, autobase majority quorum
// ((n>>>1)+1, consensus.js:15) finalizes even when the genesis/creator is offline.
// Proven by test/probe-indexer-quorum.mjs (autobase 7.28.1 holds >2 indexers fine).
const START_INDEXING = '__start_indexing__'
function toBuf (k) {
if (Buffer.isBuffer(k)) return k
const s = String(k)
return /^[0-9a-fA-F]{64}$/.test(s) ? Buffer.from(s, 'hex') : Buffer.from(s)
}
// Normalize an identity key to lowercase hex (members keys arrive 64-hex from
// router._stateBaseProviders, which z32-decodes memberIds to hex).
function normKeyHex (k) {
if (!k) return null
if (Buffer.isBuffer(k)) return k.toString('hex')
const s = String(k)
return /^[0-9a-fA-F]{64}$/.test(s) ? s.toLowerCase() : s
}
function deriveGsKeyPair (roomSeed) { return crypto.keyPair(crypto.hash([GS_NAMESPACE, toBuf(roomSeed)])) }
function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) }
// Router → instance handlers (need instance state for admission). ctx carries
// { tx, base, node, sb }. LWW-by-ts; D1b admission on optimistic writes.
function makeRouter () {
const r = new Router()
r.add('@state/set-entry', (rec, ctx) => ctx.sb._applySet(rec, ctx))
r.add('@state/del-entry', (rec, ctx) => ctx.sb._applyDel(rec, ctx))
return r
}
export class StateBaseV3 {
constructor (storagePath, roomKey, opts = {}) {
this._roomKey = Buffer.isBuffer(roomKey) ? roomKey : Buffer.from(String(roomKey), 'hex')
if (this._roomKey.length !== 32) throw new Error('roomKey must be 32 bytes (or 64-char hex)')
this._roomKeyHex = this._roomKey.toString('hex')
// Distinct v3 path — v2 'state-bases/' is left untouched (clean cutover).
this._storagePath = path.join(storagePath, 'state-bases-v3', this._roomKeyHex)
this._network = opts.network !== false
this._swarmOpts = opts.swarmOpts || null
// Designated always-on anchor: on open, claim the SOLE indexer slot via
// move-indexer so the room finalizes even when the creator is offline.
this._claimIndexer = !!opts.claimIndexer
// P3 (#103): admins self-promote to indexers not just at open but whenever their
// keet role changes at runtime (member→admin), driven by a low-frequency retry
// timer. _promoteRetryMs = how often to re-check; _promoting guards overlapping
// runs (timer + projectMembers can race). Q7 (#103): _maxIndexers bounds the
// ACTUAL indexer set so a large admin room doesn't push the majority-quorum
// threshold so high that finalization gets fragile — admins past the cap stay
// plain writers (still admitted, still write; they just don't index).
this._promoteRetryMs = Number(opts.promoteRetryMs) || Number(process.env.KEET_PROMOTE_RETRY_MS) || 30000
this._maxIndexers = Number(opts.maxIndexers) || Number(process.env.KEET_MAX_INDEXERS) || 5
this._promoting = false
this._promoteTimer = null
this._promoteFutileLogged = false
// Membership context. selfKey (this daemon's keet identity pubkey) drives the
// deterministic creator election; it comes from router._stateBaseProviders as
// `selfKey` (a 32-byte pubkey), NOT a keypair. selfKeyPair is optional (kept
// for v3.0b signing). CRITICAL: without a real selfKey the election would
// default amCreator=true on every daemon → multiple genesis → split-brain.
this._selfKeyPair = opts.selfKeyPair || null
this._selfKey = opts.selfKey
? toBuf(opts.selfKey)
: (this._selfKeyPair ? toBuf(this._selfKeyPair.publicKey) : null)
this._getMembers = typeof opts.getMembers === 'function' ? opts.getMembers : null
// Injectable crypto for membership-gated admission (keet attestation primitives).
// attest: sign localKey‖base.key as our keet identity; verifyAttest: IdentityKey.verify.
this._attest = typeof opts.attest === 'function' ? opts.attest : null
this._verifyAttest = typeof opts.verifyAttest === 'function' ? opts.verifyAttest : null
// Admission model (A): prove control of THIS daemon's keet room writer key, which
// keet maps (via @keet/devices) to our active member. roomWriterKeyPair signs the
// proof; roomWriterManifest binds the signer to the writer/device key
// (deviceKey = Hypercore.key(manifest)). Both come from room.base.local in the
// router. The verifier re-derives the device key and looks it up in __devices__
// (deviceKey→memberKey) → __members__ (memberKey→role). See hyper-fleeky-dev #99.
this._roomWriterKeyPair = opts.roomWriterKeyPair || this._selfKeyPair || null
this._roomWriterManifest = opts.roomWriterManifest || null
// Deterministic base identity from the room's members-only secret (v2 design).
this._roomSeed = opts.roomSeed ? toBuf(opts.roomSeed) : null
this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed) : null
this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : null
this._router = makeRouter()
this.store = null
this.base = null
this.swarm = null
this._ready = false
this._closing = false
this._watchers = new Set()
// Writer-hex keys we've already admitted via addWriter at apply time. A remote
// member attaches its `admit` proof to EVERY optimistic write until it observes
// itself become writable, so without this guard apply re-runs addWriter/ackWriter
// (and logs) once per op — hundreds of redundant calls per peer. addWriter is
// idempotent, so de-duping the CALL leaves the writer set identical.
this._admitted = new Set()
// Cached "self is an ADMITTED, durable writer of this room": our local writer
// key (base.local.key) is in the autobase linearized system writer set
// (base.system). This is the signal `writable` reports — it answers "will my
// durable writes persist?" correctly: true once an indexer has actually
// admitted us (at/before base.writable's localWriter activation, so it still
// covers the post-admission lag), and FALSE for a peer that is merely an
// eligible room member in __members__ but was never admitted by an indexer
// (the false-green that keying off __members__ produced — see
// hyper-fleeky-dev #99: a local identity in the roster but absent from the
// indexer's membership read writable=true while its writes never finalized).
this._selfIsAdmittedWriter = false
}
// ── Lifecycle ──────────────────────────────────────────────
async ready () {
if (this._ready) return
if (this._closing) throw new Error('StateBaseV3 is closing')
fs.mkdirSync(this._storagePath, { recursive: true })
this.store = new Corestore(this._storagePath)
await this.store.ready()
try {
await this._open()
} catch (err) {
if (!this._isCorruptionError(err)) throw err
// Diagnostic mode: report corruption WITHOUT quarantining (non-destructive
// probe of live storage). Used by tools/probe-room.mjs.
if (process.env.KEET_PROBE_NO_QUARANTINE) {
console.error('[state-base-v3] room=%s CORRUPT (%s) — probe mode, NOT quarantining',
this._roomKeyHex.slice(0, 12), err.message)
throw err
}
console.error('[state-base-v3] room=%s CORRUPT (%s) — quarantining + restarting',
this._roomKeyHex.slice(0, 12), err.message)
await this._quarantineAndReset()
}
this._ready = true
// Admins/mods AUTO-promote to indexers on open (keet model: admins ARE the
// indexers — members/index.js addWriter(indexer:isIndexing)). Best-effort +
// background so it never blocks boot; needs an online indexer to land and
// retries on the next open. Non-admins are untouched (stay plain writers),
// so a room's indexer set = its online admins (quorum tolerates the creator
// dropping once ≥3 admins index).
this._maybePromote().catch(() => {})
// P3 (#103): the open-time call above only sees our role AS OF open. An admin
// GRANTED at runtime (member→admin while the daemon stays up) would otherwise
// need a full reopen to self-promote — the ashatron footgun. Re-fire on a
// low-frequency timer so a newly-promoted admin becomes an indexer on its own.
// Cheap when idle: _maybePromote early-returns once we're a durable indexer or
// while we're not (yet) an admin. unref so it never keeps the process alive.
if (!this._promoteTimer) {
this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}) }, this._promoteRetryMs)
if (this._promoteTimer && typeof this._promoteTimer.unref === 'function') this._promoteTimer.unref()
}
}
// A DESIGNATED always-on anchor (opts.claimIndexer) automatically claims the
// sole indexer slot on open, so the room finalizes (quorum 1) even when the
// creator is offline. NOT every admin — autobase can't hold many indexers, so
// only the one designated anchor takes the slot; everyone else stays a plain
// writer (admitted on first write). Best-effort + background; needs the current
// indexer online once to sign the handoff, then it's durable in the oplog.
async _maybePromote () {
if (this._promoting) return // guard the timer/projectMembers race — one attempt at a time
this._promoting = true
try {
if (!this.base) return
if (this.base.writable && this._isLocalIndexer() === true) return // already an indexer
// keet model (members/index.js:137 `isIndexing = canAdmin`): EVERY admin is an
// indexer, so the set grows to = the online admins and majority quorum tolerates
// any single node (incl. the genesis creator) dropping. Verify we're an admin
// (role 1); non-admins stay plain writers. Best-effort + background; needs an
// online indexer to sign the addWriter (autobase invariant) — retries next open.
if (this._getMembers && this._selfKey) {
let members; try { members = await this._getMembers() } catch { members = null }
const selfHex = this._selfKey.toString('hex')
const me = Array.isArray(members) ? members.find(m => normKeyHex(m.key || m.devicePubkey || m.identity || m.memberId || m.id) === selfHex) : null
if (!me || Number(me.role) !== 1) return // non-admin → stays a plain writer
}
// Q7 (#103): bound the ACTUAL indexer set. Every admin is ELIGIBLE (keet model),
// but once the live set has _maxIndexers members we stop growing it — a room with
// many admins would otherwise push the majority threshold ((n>>>1)+1) so high that
// a couple of offline indexers stall finalization. Admins past the cap stay plain
// writers (admitted on their writes; they just don't index). We're not already an
// indexer here (early-returned above), so a full set means: leave it alone.
let idxLen = 0; try { idxLen = this.base.linearizer.indexers.length } catch { idxLen = 0 }
if (idxLen >= this._maxIndexers) {
console.log('[state-base-v3] room=%s admin NOT self-promoting: indexer set full (%d/%d) — staying a plain writer', this._roomKeyHex.slice(0, 12), idxLen, this._maxIndexers)
return
}
// AUTOBASE CONSTRAINT (7.28.1): a writer can only be elevated to indexer while it is
// FRESH — base.local.length === 0. Reliable promotion is a two-phase protocol anchored
// at length 0 (addWriter parks the key in pendingIndexers, the writer self-acks — gated
// on local.length===0, apply-state.js:356 — and the manifest fork fires anchored to that
// writer's own first confirmed node). A writer that has ALREADY appended data
// (_seenLength>0) takes system._updateIndexer's immediate branch, binding the fork to an
// un-anchored tip node authored by another indexer; the reorganising linearizer keeps
// rolling it back (_rollbackViews) → indexerUpdate is set but the indexer set never
// advances. So a data-bearing writer CANNOT be promoted in place — retrying is futile and
// just spams the log. keet has the same constraint: it mints indexers only from FRESH
// devices/joins (addDevice: addWriter(deviceKey,{indexer:isIndexing})); change-member-role
// sets the admin CAPABILITY only, never addWriter. So we promote ONLY while fresh; an
// admin that already wrote game data keeps its admin capability but stays a plain writer
// (durability is provided by the fresh/anchor indexers, not by every admin). See
// hyper-fleeky-dev #103 + memory/project_statebase-inplace-indexer-promotion-*.
let localLen = 0; try { localLen = this.base.local ? this.base.local.length : 0 } catch { localLen = 0 }
if (localLen > 0) {
if (!this._promoteFutileLogged) {
this._promoteFutileLogged = true
console.log('[state-base-v3] room=%s admin NOT self-promoting: writer already has data (local.length=%d) — autobase only elevates a FRESH (length 0) writer to indexer; keeping admin as a plain writer', this._roomKeyHex.slice(0, 12), localLen)
}
return
}
// Every admin GROWS the set via start-indexing (no demote → quorum). A DESIGNATED
// anchor (opts.claimIndexer) still does the SOLE-slot move-indexer for back-compat.
const mode = this._claimIndexer ? { claimIndexer: true } : { startIndexing: true }
console.log('[state-base-v3] room=%s admin self-promoting to indexer (%s)', this._roomKeyHex.slice(0, 12), this._claimIndexer ? 'move-indexer anchor' : 'start-indexing grow')
const r = await this.requestWriter({ timeoutMs: 25000, ...mode })
console.log('[state-base-v3] room=%s promote result writable=%s indexer=%s', this._roomKeyHex.slice(0, 12), r.writable, r.indexer)
} catch (e) { console.log('[state-base-v3] promote failed: %s', e.message) } finally { this._promoting = false }
}
async _open () {
if (!this._detBaseKey) throw new Error('StateBaseV3 requires roomSeed (deterministic key)')
const amCreator = (!this._getMembers || !this._selfKey) ? true : await this._amCreator()
console.log('[state-base-v3] room=%s %s base=%s', this._roomKeyHex.slice(0, 12),
amCreator ? 'creator' : 'member', this._detBaseKey.toString('hex').slice(0, 12))
await this._openBase(this._detBaseKey, amCreator ? this._gsKeyPair : null)
}
async _openBase (bootstrapKey, keyPair) {
const baseOpts = {
// The Autobase view IS a HyperDB. autoUpdate is load-bearing: HyperDB reads
// a snapshot frozen at construction, advanced only by core 'append'.
open: (store) => HyperDB.bee(store.get('view'), hyperdbDefinition, { autoUpdate: true }),
apply: this._apply.bind(this),
close: async (view) => { if (view) await view.close() },
valueEncoding: 'binary', // ops are hyperdispatch-encoded buffers
optimistic: true,
ackInterval: 1000,
ackThreshold: 4
}
if (keyPair) baseOpts.keyPair = keyPair
if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true }
this.base = new Autobase(this.store, bootstrapKey, baseOpts)
await this.base.ready()
if (this._network) {
if (!this.swarm) {
this.swarm = new Hyperswarm(this._swarmOpts || undefined)
this.swarm.on('connection', (socket) => {
// MUST replicate via base.replicate (not store.replicate): it ALSO
// wires the autobase wakeupProtocol onto the stream, which announces
// optimistic/pending writer cores. With bare store.replicate the
// indexer never discovers a not-yet-admitted member's optimistic
// onboarding block → admission never runs → member stays non-writable
// (the testy1/AI-Testing-Town "can't write" deadlock). cf. pear-git-v3
// pear-git.js:447 ("corestore.replicate unreliable for writer cores").
try { this.base.replicate(socket) } catch (err) { console.error('[state-base-v3] replicate threw:', err.message) }
})
}
this.swarm.join(this.base.discoveryKey)
}
console.log('[state-base-v3] room=%s base.key=%s writable=%s', this._roomKeyHex.slice(0, 12),
this.base.key.toString('hex').slice(0, 12), this.base.writable)
}
// keet's _autobaseApply commit shape: a per-batch transaction; handlers write
// into it; flush on success / close on error (never insert on the db directly).
async _apply (nodes, view, base) {
if (process.env.SBV3_DIAG) {
const who = base.writable ? 'WRITABLE' : 'member'
const froms = nodes.map(n => (n.from && n.from.key ? n.from.key.toString('hex').slice(0, 8) : 'sys')).join(',')
console.log('[SBV3_DIAG] _apply self=%s(%s) nodes=%d from=[%s]',
(this._selfKey ? this._selfKey.toString('hex').slice(0, 8) : '?'), who, nodes.length, froms)
}
const tx = view.transaction()
try {
for (const node of nodes) {
const op = node.value
if (op == null || !Buffer.isBuffer(op)) continue // null/system nodes
await this._router.dispatch(op, { tx, base, node, sb: this })
}
await tx.flush()
} catch (err) {
try { await tx.close() } catch {}
throw err
}
for (const w of this._watchers) { try { w() } catch {} }
}
// ── Public API (generic KV — unchanged surface vs v2) ──────
async set (key, value) {
if (!this._ready) throw new Error('StateBaseV3 not ready')
// DOORBELL (keet block-until-admitted). NEVER fuse durable CONTENT into a pre-admission
// OPTIMISTIC block: autobase keeps an optimistic block ONLY if the SAME apply admits+acks its
// writer, else it is rolled back every drain and discarded (apply-state.js:1110-1141). Fusing
// content+admit therefore reports success while the write silently VANISHES when no indexer is
// online to ack it — and the author can never read it back (test/silent-loss.repro.mjs). Every
// durable autobase app (autopass/keet/hyper-rooms/hypernote) instead admits FIRST, writes AFTER.
// So: if we are not yet an admitted writer, drive a CONTENT-FREE admission and block until we
// are genuinely in the system writer set; only then append content non-optimistically. If
// admission cannot be reached, _ensureWritable THROWS (never a silent loss). cf. skills/autobase.
await this._ensureWritable()
const rec = { key: String(key), value: this._enc(value), ts: Date.now() }
await this.base.append(encode('@state/set-entry', rec))
await this.base.update()
}
async del (key) {
if (!this._ready) throw new Error('StateBaseV3 not ready')
await this._ensureWritable()
await this.base.append(encode('@state/del-entry', { key: String(key), ts: Date.now() }))
await this.base.update()
}
// Block until we are a genuinely ADMITTED writer (our local writer key is in the linearized
// system writer set), then return. No-op if already writable. Otherwise ring the CONTENT-FREE
// admission doorbell (requestWriter, which re-rings until admitted) and wait; if no indexer
// admits us within the timeout, THROW rather than let a caller write content that autobase would
// silently roll back. This is the guard that makes set()/del() durable (block-until-admitted).
async _ensureWritable ({ timeoutMs = 20000 } = {}) {
if (this.base && this.base.writable) return
await this.requestWriter({ timeoutMs })
if (!this.base || !this.base.writable) {
throw new Error('StateBaseV3: not admitted as a writer (no indexer online within ' + timeoutMs + 'ms) — refusing to write content that would be silently lost')
}
}
// Trigger our OWN admission without writing any game data: append a keyless
// optimistic block carrying just the admission proof. When an online indexer
// applies it, _applySet addWriters us (INDEXER if our keet role is admin/mod,
// role 1/2). So an always-on `--pin-room` anchor that is an admin becomes a
// durable indexer and can then admit other members even when the original
// room creator is offline (fixes the "creator went away → room stuck" class).
// No-op if already writable. Requires an online indexer to land the first
// promotion (bootstrap); thereafter the writer status is durable in the oplog.
// claimIndexer: a DESIGNATED admin anchor takes the sole indexer slot (move-
// indexer). Without it, just become a plain writer. Goal reached when writable
// (and, if claiming, when we are the indexer).
async requestWriter ({ timeoutMs = 15000, claimIndexer = false, startIndexing = false } = {}) {
if (!this._ready) throw new Error('StateBaseV3 not ready')
const wantIndexer = claimIndexer || startIndexing
const done = () => this.base.writable && (!wantIndexer || this._isLocalIndexer() === true)
if (done()) return { writable: true, already: true, indexer: this._isLocalIndexer() }
const admit = await this._buildAdmissionProof()
if (!admit) throw new Error('StateBaseV3 cannot request writer: no membership/identity to build an admission proof')
// key MUST be a string (encoder always writes it; undefined crashes compact-
// encoding). MOVE_INDEXER = indexer swap (deliberate hand-off); START_INDEXING =
// grow the indexer set (self-promote, quorum path); '' = plain-writer admission.
// None insert a game entry.
const key = claimIndexer ? MOVE_INDEXER : (startIndexing ? START_INDEXING : '')
await this.base.append(encode('@state/set-entry', { key, admit, ts: Date.now() }), { optimistic: true })
await this.base.update()
const deadline = Date.now() + timeoutMs
while (!done() && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 250))
// RE-RING the doorbell while still un-admitted: a single optimistic block can be rolled back
// before an intermittent / just-connected indexer acks it (silent-loss RECOVER mode), so keep
// a live optimistic block present to re-anchor the tip until admission lands. Once writable,
// stop — further appends go through the normal (non-optimistic) writer path.
if (!this.base.writable) {
try { await this.base.append(encode('@state/set-entry', { key, admit, ts: Date.now() }), { optimistic: true }) } catch {}
}
await this.base.update().catch(() => {})
}
return { writable: this.base.writable, indexer: this._isLocalIndexer() }
}
// Is THIS daemon's local writer an indexer? autobase tracks it directly.
_isLocalIndexer () {
try { return this.base.isIndexer === true } catch { return null }
}
async get (key) {
if (!this._ready) return null
await this.base.update()
const rec = await this.base.view.get(ENTRIES, { key: String(key) })
return rec ? this._dec(rec.value) : null
}
async list (prefix = '') {
if (!this._ready) return {}
await this.base.update()
const out = {}
const stream = this.base.view.find(ENTRIES, { gte: { key: prefix }, lt: { key: prefix + '~' } })
for await (const rec of stream) out[rec.key] = this._dec(rec.value)
return out
}
// value is an arbitrary JSON-able Variant from the game; stored as a JSON string.
_enc (v) { try { return JSON.stringify(v) } catch { return String(v) } }
_dec (s) { if (typeof s !== 'string') return s; try { return JSON.parse(s) } catch { return s } }
// ── Apply-side op handlers (called by the router) ──────────
async _applySet (rec, ctx) {
// D1b admission: an optimistic write from a not-yet-admitted member carries
// an `admit` proof. Verify it deterministically (pure crypto + projected
// __members__, NO external lookup) and ackWriter so the block commits.
// Unverified optimistic blocks are NOT acked → autobase never applies them.
if (rec.admit && ctx.node && ctx.node.from && ctx.node.from.key) {
const fromKeyHex = ctx.node.from.key.toString('hex')
// Already admitted this writer — addWriter is idempotent, so skip the
// re-verify + re-addWriter + re-ack (and the log line) that the peer's
// every-op `admit` proof would otherwise trigger. Once they're a permanent
// writer their blocks commit via normal acking; no per-block ackWriter.
const fromKey = ctx.node.from.key
const fromHex = fromKeyHex.slice(0, 12)
if (rec.key === MOVE_INDEXER) {
// MOVE-INDEXER (keet members/index.js:258-275): a DESIGNATED admin anchor
// takes the SOLE indexer slot via an atomic swap — promote the anchor +
// demote the genesis creator (whose writer key is the deterministic
// base.key). Count stays 1, so the always-on anchor finalizes (quorum 1)
// even with the creator offline. We do NOT grow the indexer set: autobase
// can't reliably exceed ~2 indexers (the 3rd-indexer manifest hang, proven).
// Re-runnable (idempotent addWriter); not gated by _admitted (it's a
// promotion, not a first admission). Only admins (role 1) may claim.
const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
if (role !== 1) { console.log('[state-base-v3] MOVE-INDEXER DENIED from=%s role=%s', fromHex, String(role)); return }
try {
await ctx.base.addWriter(fromKey, { indexer: true })
this._admitted.add(fromKeyHex)
if (this._detBaseKey && Buffer.compare(fromKey, this._detBaseKey) !== 0) {
await ctx.base.addWriter(this._detBaseKey, { indexer: false }) // demote genesis creator
}
// NO preferFastForward: a fast-forwarding indexer refuses to ack/sign (autobase
// index.js:952), which deadlocks the indexer-set migration so signedLength stalls on
// ALL peers (audit 2026-07-02). keet's indexer ops don't call it. Removing it lets the
// new indexer ack and quorum reform. Proven on START_INDEXING (probe-indexer-quorum 8/8
// + probe-rotation-phantom-check 3/3 inline); MOVE-INDEXER (anchor path) same rationale.
console.log('[state-base-v3] MOVE-INDEXER: anchor=%s is now SOLE indexer (genesis demoted)', fromHex)
} catch (e) { console.log('[state-base-v3] move-indexer FAIL from=%s: %s', fromHex, e.message) }
try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
} else if (rec.key === START_INDEXING) {
// START-INDEXING (keet members/index.js op D): an admin GROWS the indexer set
// by adding ITSELF as an indexer — NO demote of anyone. With >=3 admin-indexers,
// autobase majority quorum ((n>>>1)+1) finalizes even when the genesis/creator
// is offline. Only admins (role 1) may promote; idempotent addWriter (not gated
// by _admitted — it's a promotion, and combines first-admission + indexer grow).
const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
if (role !== 1) { console.log('[state-base-v3] START-INDEXING DENIED from=%s role=%s', fromHex, String(role)); return }
try {
await ctx.base.addWriter(fromKey, { indexer: true })
this._admitted.add(fromKeyHex)
// NO preferFastForward — see MOVE-INDEXER note above (FF-deadlock, autobase index.js:952).
console.log('[state-base-v3] START-INDEXING: %s is now an indexer (grow, no demote)', fromHex)
} catch (e) { console.log('[state-base-v3] start-indexing FAIL from=%s: %s', fromHex, e.message) }
try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
} else if (!this._admitted.has(fromKeyHex)) {
const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
console.log('[state-base-v3] ADMIT attempt from=%s verifiedRole=%s', fromHex, String(role))
if (role == null) return
// Admit as a PLAIN WRITER (indexer:false). We deliberately never grow the
// indexer set; the sole indexer is the genesis creator OR a move-indexer
// anchor. Everyone else writes and the indexer signs.
try { await ctx.base.addWriter(fromKey, { indexer: false }); this._admitted.add(fromKeyHex); console.log('[state-base-v3] addWriter OK from=%s indexer=false', fromHex) } catch (e) { console.log('[state-base-v3] addWriter FAIL from=%s: %s', fromHex, e.message) }
try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
}
}
// Empty/null key = a pure admission request; MOVE_INDEXER / START_INDEXING =
// promotion requests. All ran their effect above; none write a game entry.
if (!rec.key || rec.key === MOVE_INDEXER || rec.key === START_INDEXING) return
const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
await ctx.tx.insert(ENTRIES, { key: rec.key, value: rec.value, ts: rec.ts || 0 })
}
async _applyDel (rec, ctx) {
const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
await ctx.tx.delete(ENTRIES, { key: rec.key })
}
// ── Membership projection (cross-base sync) ────────────────
// Daemon's out-of-apply watcher copies keet-room membership in so apply can
// gate admission deterministically. Stored under the reserved key __members__
// (a memberId-hex → role map). No-op until we're a writer (another writer
// projects; peers replicate it).
async projectMembers (members) {
if (!this._ready || !this.base || !this.base.writable) return false
const map = {} // memberKey(hex) -> role
const devmap = {} // keet writer/device key(hex) -> memberKey(hex) (for admission model A)
if (Array.isArray(members)) {
for (const m of members) {
const id = normKeyHex(m.key || m.devicePubkey || m.identity || m.memberId || m.id)
if (!id) continue
map[id] = Number(m.role) || 3
const devs = Array.isArray(m.devices) ? m.devices : []
for (const d of devs) { const dh = normKeyHex(d); if (dh) devmap[dh] = id }
}
} else if (members && typeof members === 'object') {
for (const id of Object.keys(members)) { const h = normKeyHex(id); if (h) map[h] = Number(members[id]) || 3 }
}
const tag = 'projectMembers ' + (this.base.key ? this.base.key.toString('hex').slice(0, 12) : '?')
await timed(tag + ' append __members__', this.base.append(encode('@state/set-entry', { key: '__members__', value: JSON.stringify(map), ts: Date.now() })))
await timed(tag + ' append __devices__', this.base.append(encode('@state/set-entry', { key: '__devices__', value: JSON.stringify(devmap), ts: Date.now() })))
await timed(tag + ' update', this.base.update())
// P3 (#103): a re-projection is the moment a role change becomes visible. If THIS
// node was just granted admin, re-check promotion immediately rather than waiting
// for the next timer tick. Background + best-effort; the timer is the general
// fallback for role changes replicated in from another writer's projection.
this._maybePromote().catch(() => {})
return true
}
// SYNCHRONOUS snapshot of the autobase's processing state, for diagnosing a base
// whose open or append never returns. Every other diagnostic awaits the base
// (update(), list(), the router's cached open promise), so when the autobase
// _advance loop is stuck they hang with it. This reads fields only, never awaits.
// Fields are autobase 7.28.1 internals (index.js: _caughtup, fastForwardTo,
// _draining, _advancing, _interrupting, _appending, _appended; activeWriters of
// writers with .core + .length) — each read is guarded, so a renamed field shows
// as null instead of throwing.
internals () {
const hex8 = (k) => (k ? Buffer.from(k).toString('hex').slice(0, 12) : null)
const get = (fn) => { try { const v = fn(); return v === undefined ? null : v } catch (e) { return 'ERR ' + e.message } }
const b = this.base
const out = {
ready: this._ready,
closing: this._closing,
hasBase: !!b,
baseKey: get(() => hex8(b.key)),
// THIS machine's own writer core. Every peer replicates every writer, so a key
// being on disk does not say whose it is; this does. Matches an entry in writers[].
localKey: get(() => hex8(b.local.key)),
opened: get(() => b.opened),
writable: get(() => b.writable),
caughtup: get(() => b._caughtup),
fastForwardTo: get(() => (b.fastForwardTo === null ? null : { length: b.fastForwardTo.length != null ? b.fastForwardTo.length : null })),
localRecovering: get(() => b.local.recovering),
localLength: get(() => b.local.length),
// true from _advance()'s start to its end: stuck-true = the processing loop is
// hung. (Not _advancing: autobase keeps the LAST promise there, so it is
// non-null on an idle, healthy base too.)
draining: get(() => b._draining),
interrupting: get(() => b._interrupting),
paused: get(() => b.paused),
appendingPending: get(() => (b._appending ? b._appending.length : 0)),
appended: get(() => b._appended),
systemPresent: get(() => b.system != null),
indexers: get(() => b.linearizer.indexers.map(w => hex8(w.core.key))),
swarmConnections: get(() => (this.swarm ? this.swarm.connections.size : null))
}
out.writers = get(() => [...b.activeWriters].map(w => ({
key: hex8(w.core.key),
applied: w.length,
coreLength: get(() => w.core.length),
contiguous: get(() => w.core.contiguousLength),
corePeers: get(() => w.core.peers.length),
isIndexer: get(() => w.isActiveIndexer)
})))
return out
}
async getProjectedRole (id) {
if (!this._ready || !this.base) return null
await this.base.update()
const mrec = await this.base.view.get(ENTRIES, { key: '__members__' })
if (!mrec || !mrec.value) return null
let members; try { members = JSON.parse(mrec.value) } catch { return null }
const h = normKeyHex(id)
const role = members[h] != null ? members[h] : members[id]
return role != null ? Number(role) : null
}
// ── Admission proof (op-embedded) ──────────────────────────
// Proof attests (over localKey‖base.key, so it can't be replayed onto another
// base) that `identity` vouches for this writer's local key. keet attestation
// (preferred) or raw ed25519 (fallback).
async _buildAdmissionProof () {
if (!this.base || !this.base.local || !this.base.local.key || !this.base.key) return null
const localKey = this.base.local.key
const signData = Buffer.concat([localKey, this.base.key])
// (A) keet-writer-control: sign signData with our keet ROOM writer keypair and
// carry that writer's manifest. The verifier derives deviceKey=Hypercore.key(manifest),
// checks sig against manifest.signers[0].publicKey, maps device→member→role.
if (this._roomWriterKeyPair && this._roomWriterKeyPair.secretKey && this._roomWriterManifest) {
const sig = crypto.sign(signData, toBuf(this._roomWriterKeyPair.secretKey))
const manifestEnc = Verifier.encodeManifest(this._roomWriterManifest)
return JSON.stringify({ manifest: Buffer.from(manifestEnc).toString('hex'), sig: sig.toString('hex') })
}
// legacy keet-attestation / raw-ed25519 fallback (older callers + synthetic tests)
if (this._attest && this._selfKey) {
const proof = await this._attest(signData)
if (!proof) return null
const pbuf = Buffer.isBuffer(proof) ? proof : Buffer.from(proof)
return JSON.stringify({ identity: this._selfKey.toString('hex'), proof: pbuf.toString('hex') })
}
if (this._selfKeyPair && this._selfKeyPair.secretKey) {
const sig = crypto.sign(signData, toBuf(this._selfKeyPair.secretKey))
return JSON.stringify({ identity: toBuf(this._selfKeyPair.publicKey).toString('hex'), proof: sig.toString('hex') })
}
return null
}
// DETERMINISTIC in-apply verification (pure crypto + projected __members__).
async _verifyAdmissionInApply (localKey, admitStr, tx) {
try {
const admit = typeof admitStr === 'string' ? JSON.parse(admitStr) : admitStr
if (!admit || !this.base || !this.base.key) return null
const signData = Buffer.concat([localKey, this.base.key])
// (A) keet-writer-control proof: { manifest, sig }. Re-derive the keet writer/
// device key from the manifest, verify the sig against the manifest's signer,
// then resolve device→member (__devices__) → role (__members__).
if (admit.manifest && admit.sig) {
let manifest, device, signerPub
try {
manifest = Verifier.decodeManifest(Buffer.from(admit.manifest, 'hex'))
device = Hypercore.key(manifest)
signerPub = manifest.signers && manifest.signers[0] && manifest.signers[0].publicKey
} catch (e) { console.log('[state-base-v3] verify: bad manifest %s', e.message); return null }
if (!signerPub) { console.log('[state-base-v3] verify: manifest has no signer'); return null }
if (!crypto.verify(signData, Buffer.from(admit.sig, 'hex'), signerPub)) {
console.log('[state-base-v3] verify: writer sig INVALID for device=%s', device.toString('hex').slice(0, 12)); return null
}
const drec = await tx.get(ENTRIES, { key: '__devices__' })
const devmap = drec && drec.value ? JSON.parse(drec.value) : {}
const deviceHex = device.toString('hex')
const memberKey = devmap[deviceHex] != null ? devmap[deviceHex] : devmap[normKeyHex(deviceHex)]
if (!memberKey) { console.log('[state-base-v3] verify: device=%s NOT in __devices__', deviceHex.slice(0, 12)); return null }
const mrec = await tx.get(ENTRIES, { key: '__members__' })
const members = mrec && mrec.value ? JSON.parse(mrec.value) : {}
const role = members[memberKey] != null ? members[memberKey] : members[normKeyHex(memberKey)]
if (role == null) console.log('[state-base-v3] verify: member=%s (device %s) NOT in __members__', String(memberKey).slice(0, 12), deviceHex.slice(0, 12))
return role != null ? Number(role) : null
}
// legacy keet-attestation / raw-ed25519 proof: { identity, proof }
if (!admit.identity || !admit.proof) return null
const identity = Buffer.from(admit.identity, 'hex')
const proof = Buffer.from(admit.proof, 'hex')
let ok = false
if (this._verifyAttest) ok = await this._verifyAttest(proof, signData, identity)
else { if (proof.length !== 64) { console.log('[state-base-v3] verify: bad proof len'); return null }; ok = crypto.verify(signData, proof, identity) }
if (!ok) { console.log('[state-base-v3] verify: proof INVALID for identity=%s (verifyAttest=%s)', admit.identity.slice(0, 12), !!this._verifyAttest); return null }
const mrec = await tx.get(ENTRIES, { key: '__members__' })
if (!mrec || !mrec.value) { console.log('[state-base-v3] verify: no __members__ in view'); return null }
const members = JSON.parse(mrec.value)
const role = members[admit.identity] != null ? members[admit.identity]
: (members[normKeyHex(admit.identity)] != null ? members[normKeyHex(admit.identity)] : null)
if (role == null) console.log('[state-base-v3] verify: identity=%s NOT in __members__ (keys=%s)', admit.identity.slice(0, 12), Object.keys(members).map(k => k.slice(0, 8)).join(','))
return role != null ? Number(role) : null
} catch (e) { console.log('[state-base-v3] verify: threw %s', e.message); return null }
}
watch (fn) { this._watchers.add(fn); return () => this._watchers.delete(fn) }
// `writable` answers "will my durable writes persist?" — true once autobase has
// activated our local writer (base.writable), OR an indexer has already admitted
// our local writer into the linearized system writer set (cached
// _selfIsAdmittedWriter). The admitted-writer signal is true at/before
// base.writable's localWriter activation, so it still covers the post-admission
// lag WITHOUT the false-positive of keying off __members__: room membership is a
// SUPERSET of admitted writers, so an eligible member the indexer never admitted
// (e.g. an identity absent from the indexer's own membership) must NOT read
// writable. Sync getter uses the cache; callers wanting a fresh answer await
// effectiveWritable(). See hyper-fleeky-dev #99.
get writable () { return this.base?.writable || false }
// Fresh "will my writes persist?" — refreshes the _selfIsAdmittedWriter cache.
async effectiveWritable () {
if (this.base && this.base.writable) return true
return await this._refreshSelfWriter()
}
// Re-derive whether our LOCAL WRITER KEY (base.local.key) is in the autobase
// linearized system writer set (base.system) — i.e. an indexer has durably
// admitted us, so our writes finalize. This is the authoritative "admitted"
// signal — the same one autobase's own fast-forward consults
// (lib/fast-forward.js: system.get(base.local.key)). It is NOT mere room
// membership: __members__ gates admission (_verifyAdmissionInApply), but being
// in it does not mean an indexer actually admitted us (#99). base.system
// (=_applyState.system) is null transiently on reboot/close → keep the cache.
async _refreshSelfWriter () {
try {
if (!this._ready || !this.base || !this.base.local || !this.base.local.key) return this._selfIsAdmittedWriter
await this.base.update()
const sys = this.base.system
if (!sys) return this._selfIsAdmittedWriter
this._selfIsAdmittedWriter = await sys.has(this.base.local.key)
return this._selfIsAdmittedWriter
} catch { return this._selfIsAdmittedWriter }
}
get stateBaseKey () { return this.base?.key || null }
get hasBootstrap () { return !!this._detBaseKey }
// ── creator election (v2 logic) ────────────────────────────
async _amCreator () {
if (!this._getMembers || !this._selfKey) return false
let members
try { members = await this._getMembers() } catch { return false }
if (!Array.isArray(members) || members.length === 0) return false
const norm = (k) => (Buffer.isBuffer(k) ? k.toString('hex') : String(k)).toLowerCase()
const eligible = members
.map(m => ({ key: norm(m.key || m.devicePubkey || m.identity || m.memberId), role: Number(m.role) }))
.filter(m => m.key && (m.role === 1 || m.role === 2))
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
if (eligible.length === 0) return false
return eligible[0].key === this._selfKey.toString('hex')
}
// ── corruption recovery (same posture as v2 1.0.2) ─────────
_isCorruptionError (err) {
const m = (err && err.message ? err.message : String(err)).toUpperCase()
return m.includes('DECODING_ERROR') || m.includes('UNKNOWN WIRE TYPE') || m.includes('CORRUPT') ||
m.includes('INVALID DEVICE FILE') || m.includes('CHECKSUM') || m.includes('INVALID OPLOG') || m.includes('INVALID HEADER')
}
async _quarantineAndReset () {
if (this.swarm) { try { await this.swarm.destroy() } catch {} ; this.swarm = null }
if (this.base) { try { await this.base.close() } catch {} ; this.base = null }
if (this.store) { try { await this.store.close() } catch {} ; this.store = null }
const quarantine = this._storagePath + '.corrupt-' + Date.now()
fs.renameSync(this._storagePath, quarantine)
console.error('[state-base-v3] room=%s quarantined -> %s', this._roomKeyHex.slice(0, 12), quarantine)
fs.mkdirSync(this._storagePath, { recursive: true })
this.store = new Corestore(this._storagePath)
await this.store.ready()
await this._open()
}
async close () {
if (this._closing) return
this._closing = true
if (this._promoteTimer) { clearInterval(this._promoteTimer); this._promoteTimer = null }
if (this.swarm) { await this.swarm.destroy(); this.swarm = null }
if (this.base) { await this.base.close(); this.base = null }
if (this.store) { await this.store.close(); this.store = null }
this._watchers.clear()
this._ready = false
this._closing = false
}
}