Part of #353, but a live bug today — worth fixing regardless of whether the rest of that epic ships.
Problem or Motivation
Restoring a backup with the identity onto a radio whose current public key differs silently discards the restored preferences and automation rules. That is precisely the flagship recovery scenario from #299: the radio died, and the backup is being restored onto a replacement.
The cause is ordering. session.ts:127 awaits flushSessionAsync before importPrivateKey at session.ts:134:
const persisted = await flushSessionAsync(client); // writes under the OLD pubkey
...
await client.importPrivateKey(key); // identity changes here
flushSessionAsync reads client.selfInfo.pubkey and the module-level storageKey (persistence.ts:117-129), both bound at connect from the pre-import identity. So all four blobs are written into the old identity's namespace, encrypted under the old radio-derived key.
The identity then swaps and the radio reboots. The dropped link routes to beginReconnect, not teardownSession (useMeshCore.ts:460), so store.reset() never runs and the in-memory data survives. On reconnect the app derives a fresh key and namespace from the new public key and hydrates an empty record. From there the four blobs diverge:
msgHistory survives, by accident. restoreHistory merges rather than replaces (meshStore.ts:1468) and always returns a fresh object, which trips the save subscription's state.msgHistory !== prev.msgHistory check and rewrites it under the new key.
advertCache survives — merged, then written explicitly by flushAdvertCache(c).
preferences are lost. A null blob coerces to {} and every field normalizes to its default (meshStore.ts:1338).
automationRules are lost. restoreAutomationRules(rules ?? []) is a plain set({ automationRules }) (meshStore.ts:1718); the ?? [] wipes them.
Two further consequences:
- The write leaves an orphaned encrypted record under the old public key that nothing ever collects.
applyBackup returns persisted: true, and the UI reports success, for a write that landed in a namespace the user will never read again.
Proposed Solution
Make a deliberate public-key change carry the browser data with it, rather than flushing into the outgoing namespace.
- Do not flush before the identity write when
opts.restoreIdentity is set and the backup's identity differs from the live one. The in-memory store already survives the reconnect; the flush is what misfires.
- After the reconnect binds the new key, write all four blobs under the new namespace — not only the two that currently survive by side effect. The hydrate must not normalize preferences and automation rules to defaults when the store already holds restored values for them; the existing
explicit flag on restorePreferences is the precedent for distinguishing the two cases.
- Delete the old namespace's records once the new ones are written, so a deliberate identity change does not leave a readable orphan behind.
ApplyResult.persisted must describe the namespace the user will actually read from.
This is a prerequisite for the seed-identity wizard, which performs the same import-then-reboot sequence on every run.
Verification
Restore a backup containing an identity, preferences and automation rules onto a radio with a different current identity. After the reboot and reconnect, all four blobs must be present, and the old record must be gone.
Part of #353, but a live bug today — worth fixing regardless of whether the rest of that epic ships.
Problem or Motivation
Restoring a backup with the identity onto a radio whose current public key differs silently discards the restored preferences and automation rules. That is precisely the flagship recovery scenario from #299: the radio died, and the backup is being restored onto a replacement.
The cause is ordering. session.ts:127 awaits
flushSessionAsyncbeforeimportPrivateKeyat session.ts:134:flushSessionAsyncreadsclient.selfInfo.pubkeyand the module-levelstorageKey(persistence.ts:117-129), both bound at connect from the pre-import identity. So all four blobs are written into the old identity's namespace, encrypted under the old radio-derived key.The identity then swaps and the radio reboots. The dropped link routes to
beginReconnect, notteardownSession(useMeshCore.ts:460), sostore.reset()never runs and the in-memory data survives. On reconnect the app derives a fresh key and namespace from the new public key and hydrates an empty record. From there the four blobs diverge:msgHistorysurvives, by accident.restoreHistorymerges rather than replaces (meshStore.ts:1468) and always returns a fresh object, which trips the save subscription'sstate.msgHistory !== prev.msgHistorycheck and rewrites it under the new key.advertCachesurvives — merged, then written explicitly byflushAdvertCache(c).preferencesare lost. A null blob coerces to{}and every field normalizes to its default (meshStore.ts:1338).automationRulesare lost.restoreAutomationRules(rules ?? [])is a plainset({ automationRules })(meshStore.ts:1718); the?? []wipes them.Two further consequences:
applyBackupreturnspersisted: true, and the UI reports success, for a write that landed in a namespace the user will never read again.Proposed Solution
Make a deliberate public-key change carry the browser data with it, rather than flushing into the outgoing namespace.
opts.restoreIdentityis set and the backup's identity differs from the live one. The in-memory store already survives the reconnect; the flush is what misfires.explicitflag onrestorePreferencesis the precedent for distinguishing the two cases.ApplyResult.persistedmust describe the namespace the user will actually read from.This is a prerequisite for the seed-identity wizard, which performs the same import-then-reboot sequence on every run.
Verification
Restore a backup containing an identity, preferences and automation rules onto a radio with a different current identity. After the reboot and reconnect, all four blobs must be present, and the old record must be gone.