| … | … | @@ -135,6 +135,32 @@ async function boot () { |
| 135 | 135 | // |
| 136 | 136 | // loadConfig reads PEER_CONFIG, which is set above, and also applies the PEER_* env overrides — |
| 137 | 137 | // none of which are set on a handset, so this is the same file, correctly shaped. |
| 138 | // ── REPAIR BEFORE LOAD: an identity minted by 0.1.6 has no storeBackend ────────────────── | |
| 139 | // Those DIDs boot on the legacy inline substrate and cannot post — the appview refuses to | |
| 140 | // materialise their record bytes ("refusing to CREATE bytes for a roster DID"). 0.3.121 fixed the | |
| 141 | // mint; it does nothing for an identity that already exists, and that identity is a permanently | |
| 142 | // registered did:plc with a sealed recovery vault, so wiping storage to change one field is not an | |
| 143 | // option. lib/repair-store-backend.js decides which entries may move (minted HERE, never joined); | |
| 144 | // hyper-proto-peer's marker-guarded migrateInline does the actual, tested conversion at boot. | |
| 145 | // | |
| 146 | // Written back to the RAW file, not through loadConfig: loadConfig normalises and overlays env, so | |
| 147 | // persisting its output would write derived values back as if they were the operator's. | |
| 148 | try { | |
| 149 | const { repairStoreBackend } = await import('./repair-store-backend.js') | |
| 150 | const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) | |
| 151 | const { changed } = repairStoreBackend(raw) | |
| 152 | if (changed.length) { | |
| 153 | // Keep the previous file. This is the one write that touches an identity's config, and the old | |
| 154 | // copy is the fastest way back if the migration below turns out to be wrong for this repo. | |
| 155 | try { fs.copyFileSync(cfgPath, cfgPath + '.bak.' + Math.floor(Date.now() / 1000)) } catch {} | |
| 156 | fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + '\n') | |
| 157 | log('repaired storeBackend — these identities were minted before 0.3.121 and could not post', { dids: changed }) | |
| 158 | } | |
| 159 | } catch (e) { | |
| 160 | // Loud, not fatal: a node that boots read-only is better than a node that does not boot. | |
| 161 | log('storeBackend repair skipped', { error: (e && e.message) || String(e) }) | |
| 162 | } | |
| 163 | ||
| 138 | 164 | const { loadConfig } = await import('hyper-proto-peer/lib/config.js') |
| 139 | 165 | const cfg = loadConfig() |
| 140 | 166 | // deviceMnemonic is DEVICE-scoped: `coowner-join` mints it at config ROOT so a phone can generate |
| … | … | @@ -0,0 +1,67 @@ |
| 1 | // Which config entries may be moved onto the current record backend — and, more importantly, which may not. | |
| 2 | // | |
| 3 | // favela-android 0.1.6 minted identities without `storeBackend`. bootSwarmDevice defaults that to null, | |
| 4 | // and only `storeBackend === 'overlay'` constructs the OverlayRepoStore, so those DIDs boot on the legacy | |
| 5 | // inline substrate: they author commits, and the appview refuses to materialise their record bytes. | |
| 6 | // Posting is silently broken (observed on a handset 2026-08-25 with did:plc:zl5loetx46p6xusgc47h3mia). | |
| 7 | // | |
| 8 | // hyper-proto-peer 0.3.121 fixed the mint. It does nothing for an identity already created — and that | |
| 9 | // identity is not disposable: it is a real did:plc, permanently registered in a global append-only log, | |
| 10 | // with a recovery vault sealed under a passphrase. On a release-signed app its config is app-private, so | |
| 11 | // there is no way to edit it from outside either. Wiping app storage would destroy the identity, the | |
| 12 | // vault and the node's storage in order to change one field. The repair belongs in the app. | |
| 13 | // | |
| 14 | // ── THIS FILE IS NOT THE MIGRATION ─────────────────────────────────────────────────────────────── | |
| 15 | // Flipping a POPULATED legacy DID to 'overlay' is a genuine hazard, and hyper-proto-peer documents it as | |
| 16 | // the e2jev case (hyper-social#16 P1): under PointerSubstrate its inline records read as an EMPTY set, so | |
| 17 | // the first sign() would materialize an empty MST root and publish a delete-every-record commit. The | |
| 18 | // anti-wipe guard does not catch it, because that guard looks for record FILES and a legacy DID has none. | |
| 19 | // | |
| 20 | // What makes the flip safe is already written and already tested: device-node.js runs migrateInline at | |
| 21 | // boot for exactly this shape — marker-guarded by a durable __inline_migrated__ so it fires once, and its | |
| 22 | // fromInline branch writes the file FROM the inline copy before publishing the pointer, refusing any key | |
| 23 | // whose oid would change. Its suite proves the dangerous arm directly ("sign() REFUSED — no | |
| 24 | // delete-every-record commit was published", then "after migration the set reads NON-empty") and proves | |
| 25 | // it is safe to re-run (5/5, 28/28). | |
| 26 | // | |
| 27 | // So the only decision left is WHICH entries are allowed into that path, and that is all this file does. | |
| 28 | // | |
| 29 | // ── THE DISCRIMINATOR IS forceCreator, AND IT IS THE WHOLE POINT ───────────────────────────────── | |
| 30 | // mint writes `forceCreator: true` and no `joiner`. coownerJoin writes `joiner: true` and DELETES | |
| 31 | // forceCreator. So "this node created this identity" is expressible from the config alone, and a | |
| 32 | // co-owned identity this phone merely JOINED is never touched: its records may already be pointer-shaped | |
| 33 | // on the origin, its backend is the origin's decision, and a handset must not be able to migrate someone | |
| 34 | // else's production repo as a side effect of booting. | |
| 35 | // | |
| 36 | // An ABSENT field means "never decided" — that is the 0.1.6 bug. An entry that says 'legacy' has decided, | |
| 37 | // and is left alone. Silence and a choice are different things. | |
| 38 | ||
| 39 | const OVERLAY = 'overlay' | |
| 40 | ||
| 41 | /** | |
| 42 | * Move minted-here entries with no declared backend onto the current one, in place. | |
| 43 | * | |
| 44 | * Mutates `cfg`. Returns the DIDs changed so the caller can persist ONLY when something did — a repair | |
| 45 | * that rewrote the config on every boot would be a write to storage we care about, for nothing. | |
| 46 | * | |
| 47 | * Never throws: it runs before the node exists and must not be why a phone fails to start. | |
| 48 | * | |
| 49 | * @param {object|null} cfg the raw hpp config ({ dids: [...] }) | |
| 50 | * @returns {{ changed: string[] }} | |
| 51 | */ | |
| 52 | export function repairStoreBackend (cfg) { | |
| 53 | const changed = [] | |
| 54 | const dids = cfg && Array.isArray(cfg.dids) ? cfg.dids : [] | |
| 55 | for (const entry of dids) { | |
| 56 | if (!entry || typeof entry !== 'object') continue | |
| 57 | // Already answered — either correctly, or deliberately otherwise. | |
| 58 | if (entry.storeBackend) continue | |
| 59 | // Not ours to decide. `joiner` alone is the co-owner case; BOTH flags is an entry we did not write, | |
| 60 | // and guessing at an incoherent config is how a repair becomes an incident. | |
| 61 | if (entry.joiner) continue | |
| 62 | if (entry.forceCreator !== true) continue | |
| 63 | entry.storeBackend = OVERLAY | |
| 64 | if (entry.did) changed.push(entry.did) | |
| 65 | } | |
| 66 | return { changed } | |
| 67 | } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.7", | |
| 4 | "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.", | |
| 3 | "version": "0.1.8", | |
| 4 | "description": "favela-android \u2014 a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.", | |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| 7 | 7 | "main": "lib/node.js", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons", |
| 12 | "test": "bare test/first-run.test.mjs && bare test/mint.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs", | |
| 12 | "test": "bare test/first-run.test.mjs && bare test/repair-store-backend.test.mjs && bare test/mint.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs", | |
| 13 | 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size));fs.writeFileSync(p+'.built',String(Date.now()))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs", |
| 15 | 15 | "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk" |
| … | … | @@ -33,7 +33,7 @@ |
| 33 | 33 | "pgh-runtime": "file:../pgh-runtime" |
| 34 | 34 | }, |
| 35 | 35 | "pgh": { |
| 36 | "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only — see spec/architecture.md §2 and favela-web/spec/mobile-node.md §7." | |
| 36 | "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only \u2014 see spec/architecture.md \u00a72 and favela-web/spec/mobile-node.md \u00a77." | |
| 37 | 37 | }, |
| 38 | 38 | "overrides": { |
| 39 | 39 | "hypercore": "11.34.0", |
| … | … | @@ -0,0 +1,89 @@ |
| 1 | // Repairing an identity minted onto the LEGACY record backend. | |
| 2 | // | |
| 3 | // favela-android 0.1.6 minted did:plc:zl5loetx46p6xusgc47h3mia without `storeBackend`. bootSwarmDevice | |
| 4 | // defaults that to null and only `storeBackend === 'overlay'` builds the OverlayRepoStore, so the DID | |
| 5 | // came up on the legacy inline substrate: it authored commits, and the appview refused to materialise | |
| 6 | // its post bytes ("refusing to CREATE bytes for a roster DID"). Posting was silently broken. | |
| 7 | // | |
| 8 | // The identity itself is fine — a real did:plc, permanently registered, with a sealed recovery vault. | |
| 9 | // Wiping app storage to change one config field would destroy all three to fix none of them, and on a | |
| 10 | // release-signed app the config is app-private, so there is no way to edit it from outside either. The | |
| 11 | // repair has to be in the app. | |
| 12 | // | |
| 13 | // ⛔ WHY THIS IS SAFE, AND WHERE THE DANGER ACTUALLY IS. Flipping a populated legacy DID to 'overlay' is | |
| 14 | // the e2jev hazard (hyper-social#16 P1): under PointerSubstrate its inline records read as an EMPTY set, | |
| 15 | // and the first sign() would publish a delete-every-record commit. What makes the flip safe is that | |
| 16 | // hyper-proto-peer already runs migrateInline at boot for exactly this — marker-guarded | |
| 17 | // (__inline_migrated__), idempotent, content-preserving, and it REFUSES any key whose oid would change. | |
| 18 | // Its own suite proves the dangerous arm: "sign() REFUSED — no delete-every-record commit was published", | |
| 19 | // then "after migration the set reads NON-empty" (5/5, 28/28). | |
| 20 | // | |
| 21 | // So this file is not the migration. It is the narrow decision of WHICH entries may be flipped into it. | |
| 22 | // | |
| 23 | // ⛔ THE DISCRIMINATOR IS forceCreator. mint writes forceCreator:true and no `joiner`; coownerJoin writes | |
| 24 | // joiner:true and DELETES forceCreator. So "this node minted it" is expressible, and a co-owned identity | |
| 25 | // this phone merely joined is never touched — its backend is the origin's decision, not ours, and | |
| 26 | // migrating someone else's production repo from a handset is not a thing this should be able to do. | |
| 27 | // | |
| 28 | // Run: bare test/repair-store-backend.test.mjs | |
| 29 | import { repairStoreBackend } from '../lib/repair-store-backend.js' | |
| 30 | ||
| 31 | let pass = 0 | |
| 32 | const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ } | |
| 33 | ||
| 34 | const minted = (did) => ({ did, deviceMnemonic: 'x', signingKey: 'y', members: ['aa'], forceCreator: true }) | |
| 35 | const joined = (did) => ({ did, deviceMnemonic: 'x', signingKey: 'y', members: ['aa'], joiner: true }) | |
| 36 | ||
| 37 | // ── the case this exists for ───────────────────────────────────────────────────────────────────── | |
| 38 | { | |
| 39 | const cfg = { dids: [minted('did:plc:zl5loetx46p6xusgc47h3mia')] } | |
| 40 | const r = repairStoreBackend(cfg) | |
| 41 | ok(cfg.dids[0].storeBackend === 'overlay', 'a minted entry with no backend is flipped to overlay') | |
| 42 | ok(r.changed.length === 1 && r.changed[0] === 'did:plc:zl5loetx46p6xusgc47h3mia', 'and reported by DID') | |
| 43 | } | |
| 44 | ||
| 45 | // ── everything it must NOT touch ───────────────────────────────────────────────────────────────── | |
| 46 | { | |
| 47 | // Already correct — nothing to do, and reporting a change would make the caller write the file for no | |
| 48 | // reason on every single boot. | |
| 49 | const cfg = { dids: [{ ...minted('did:plc:a'), storeBackend: 'overlay' }] } | |
| 50 | ok(repairStoreBackend(cfg).changed.length === 0, 'an entry already on overlay is left alone') | |
| 51 | } | |
| 52 | { | |
| 53 | // A DELIBERATE legacy choice is still a choice. Only an ABSENT field means "never decided". | |
| 54 | const cfg = { dids: [{ ...minted('did:plc:b'), storeBackend: 'legacy' }] } | |
| 55 | repairStoreBackend(cfg) | |
| 56 | ok(cfg.dids[0].storeBackend === 'legacy', 'an explicit non-overlay backend is not overridden') | |
| 57 | } | |
| 58 | { | |
| 59 | // ⛔ THE ONE THAT MATTERS. A joined identity is co-owned: its records may already be pointer-shaped on | |
| 60 | // the origin, and its backend is the origin's call. A handset must not migrate someone else's repo. | |
| 61 | const cfg = { dids: [joined('did:plc:wj62ebbusln6gxo5azo6')] } | |
| 62 | const r = repairStoreBackend(cfg) | |
| 63 | ok(cfg.dids[0].storeBackend === undefined, 'a JOINED identity is never flipped') | |
| 64 | ok(r.changed.length === 0, 'and never reported as changed') | |
| 65 | } | |
| 66 | { | |
| 67 | // Belt and braces: both flags set is an incoherent entry we did not write. Refuse rather than guess. | |
| 68 | const cfg = { dids: [{ ...minted('did:plc:c'), joiner: true }] } | |
| 69 | repairStoreBackend(cfg) | |
| 70 | ok(cfg.dids[0].storeBackend === undefined, 'an entry claiming BOTH roles is left alone') | |
| 71 | } | |
| 72 | ||
| 73 | // ── shape robustness ───────────────────────────────────────────────────────────────────────────── | |
| 74 | // This runs at boot, before the node exists. It must never be the reason a phone fails to start. | |
| 75 | ok(repairStoreBackend(null).changed.length === 0, 'a null config is not a crash') | |
| 76 | ok(repairStoreBackend({}).changed.length === 0, 'a config with no dids is not a crash') | |
| 77 | ok(repairStoreBackend({ dids: 'nonsense' }).changed.length === 0, 'a malformed dids is not a crash') | |
| 78 | ok(repairStoreBackend({ dids: [null, minted('did:plc:d')] }).changed.length === 1, 'a null entry is skipped, not fatal') | |
| 79 | ||
| 80 | // ── idempotent ─────────────────────────────────────────────────────────────────────────────────── | |
| 81 | // The caller persists only when something changed, so a second boot must report nothing — otherwise | |
| 82 | // every boot rewrites the config file of a device whose storage we care about. | |
| 83 | { | |
| 84 | const cfg = { dids: [minted('did:plc:e')] } | |
| 85 | ok(repairStoreBackend(cfg).changed.length === 1, 'first pass changes it') | |
| 86 | ok(repairStoreBackend(cfg).changed.length === 0, 'second pass changes nothing') | |
| 87 | } | |
| 88 | ||
| 89 | console.log('\n' + pass + ' assertions passed') |