OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / lib / repair-store-backend.js

68 lines · javascript ref:
// Which config entries may be moved onto the current record backend — and, more importantly, which may not.
//
// favela-android 0.1.6 minted identities without `storeBackend`. bootSwarmDevice defaults that to null,
// and only `storeBackend === 'overlay'` constructs the OverlayRepoStore, so those DIDs boot on the legacy
// inline substrate: they author commits, and the appview refuses to materialise their record bytes.
// Posting is silently broken (observed on a handset 2026-08-25 with did:plc:zl5loetx46p6xusgc47h3mia).
//
// hyper-proto-peer 0.3.121 fixed the mint. It does nothing for an identity already created — and that
// identity is not disposable: it is a real did:plc, permanently registered in a global append-only log,
// with a recovery vault sealed under a passphrase. On a release-signed app its config is app-private, so
// there is no way to edit it from outside either. Wiping app storage would destroy the identity, the
// vault and the node's storage in order to change one field. The repair belongs in the app.
//
// ── THIS FILE IS NOT THE MIGRATION ───────────────────────────────────────────────────────────────
// Flipping a POPULATED legacy DID to 'overlay' is a genuine hazard, and hyper-proto-peer documents it as
// the e2jev case (hyper-social#16 P1): under PointerSubstrate its inline records read as an EMPTY set, so
// the first sign() would materialize an empty MST root and publish a delete-every-record commit. The
// anti-wipe guard does not catch it, because that guard looks for record FILES and a legacy DID has none.
//
// What makes the flip safe is already written and already tested: device-node.js runs migrateInline at
// boot for exactly this shape — marker-guarded by a durable __inline_migrated__ so it fires once, and its
// fromInline branch writes the file FROM the inline copy before publishing the pointer, refusing any key
// whose oid would change. Its suite proves the dangerous arm directly ("sign() REFUSED — no
// delete-every-record commit was published", then "after migration the set reads NON-empty") and proves
// it is safe to re-run (5/5, 28/28).
//
// So the only decision left is WHICH entries are allowed into that path, and that is all this file does.
//
// ── THE DISCRIMINATOR IS forceCreator, AND IT IS THE WHOLE POINT ─────────────────────────────────
// mint writes `forceCreator: true` and no `joiner`. coownerJoin writes `joiner: true` and DELETES
// forceCreator. So "this node created this identity" is expressible from the config alone, and a
// co-owned identity this phone merely JOINED is never touched: its records may already be pointer-shaped
// on the origin, its backend is the origin's decision, and a handset must not be able to migrate someone
// else's production repo as a side effect of booting.
//
// An ABSENT field means "never decided" — that is the 0.1.6 bug. An entry that says 'legacy' has decided,
// and is left alone. Silence and a choice are different things.

const OVERLAY = 'overlay'

/**
 * Move minted-here entries with no declared backend onto the current one, in place.
 *
 * Mutates `cfg`. Returns the DIDs changed so the caller can persist ONLY when something did — a repair
 * that rewrote the config on every boot would be a write to storage we care about, for nothing.
 *
 * Never throws: it runs before the node exists and must not be why a phone fails to start.
 *
 * @param {object|null} cfg  the raw hpp config ({ dids: [...] })
 * @returns {{ changed: string[] }}
 */
export function repairStoreBackend (cfg) {
  const changed = []
  const dids = cfg && Array.isArray(cfg.dids) ? cfg.dids : []
  for (const entry of dids) {
    if (!entry || typeof entry !== 'object') continue
    // Already answered — either correctly, or deliberately otherwise.
    if (entry.storeBackend) continue
    // Not ours to decide. `joiner` alone is the co-owner case; BOTH flags is an entry we did not write,
    // and guessing at an incoherent config is how a repair becomes an incident.
    if (entry.joiner) continue
    if (entry.forceCreator !== true) continue
    entry.storeBackend = OVERLAY
    if (entry.did) changed.push(entry.did)
  }
  return { changed }
}