diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index faedf2b..9107951 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,7 @@ import { getBootstrapPublishedAt, markBootstrapPublished, resetBootstrapGate, + resetForRepair, applyOperatorCassettesConfig, getFeeConfig, getLastKnownFeeConfigCreatedAt, @@ -355,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +ipcMain.handle('state:reset-for-repair', (): void => { + resetForRepair() +}) // QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index ba53b62..a811eab 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // then relaunch so the normal boot flow pairs it. @@ -239,6 +240,7 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: ( diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 7ea3e16..a274565 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -540,6 +540,32 @@ export function resetBootstrapGate(): void { db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') } +/** + * Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend, + * so stale policy from the previous pairing can't linger or silently reject the + * new operator's config. + * + * Clears the fee config and resets BOTH replay watermarks to 0. The watermark + * reset is the load-bearing part: without it, a new backend whose first config + * event has a lower `created_at` than the old operator's last event is silently + * dropped as a replay — the exact remnant trap where re-pairing a long-lived + * install to a fresh backend appears to "work" but never picks up new config. + * + * Deliberately does NOT touch cassettes / cashbox / transactions: those track + * PHYSICAL cash, which survives an operator handover. A full wipe (decommission + * or a truly-fresh test) is the factory-reset path, not this. + */ +export function resetForRepair(): void { + if (!db) throw new Error('Database not initialized') + const database = db + database.transaction(() => { + database.prepare('DELETE FROM fee_config').run() + const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?') + setWatermark.run('0', 'lastKnownFeeConfigCreatedAt') + setWatermark.run('0', 'lastKnownConfigCreatedAt') + })() +} + export type OperatorCassettesPayload = { positions: Record } diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 2d29ab6..f830073 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -144,6 +144,15 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: (