OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 95a40a0

0.1.8: migrate an identity minted on the legacy backend, instead of wiping it
0.1.6 minted identities without `storeBackend`. bootSwarmDevice defaults that to null and only 'overlay' builds the OverlayRepoStore, so those DIDs came up on the legacy inline substrate: they authored commits and the appview refused to materialise their record bytes. Posting was silently broken — observed on a handset with did:plc:zl5loetx46p6xusgc47h3mia. hyper-proto-peer 0.3.121 fixed the mint. It does nothing for an identity that already exists, and that identity is not disposable: a permanently registered did:plc with a recovery vault sealed under a passphrase. On a release-signed app the config is app-private, so it cannot be edited from outside either — the only other route was wiping app storage, which destroys the identity, the vault and the node's storage to change one field. This repairs it in place. lib/repair-store-backend.js decides WHICH entries may move; hyper-proto-peer's migrateInline does the conversion at boot — marker-guarded by __inline_migrated__, idempotent, content-preserving, refusing any key whose oid would change. That migration is already proven against the dangerous arm: "sign() REFUSED — no delete-every-record commit was published", then "after migration the set reads NON-empty" (5/5, 28/28). ⛔ THE DISCRIMINATOR IS forceCreator. 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 backend is the origin's decision, and a handset must not migrate someone else's production repo as a side effect of booting. An ABSENT field means "never decided"; an explicit 'legacy' has decided and is left alone. The config is backed up before the one write that touches it, and a failure is logged rather than fatal: a node that boots read-only beats a node that does not boot. 13 assertions, including that a joined identity is never flipped and that a second boot changes nothing. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95a40a045df4bbb4f9c98e6a4b25683b367552a2
by plankatron <fleeky@gmail.com> · 2026-08-26T00:14:46.000Z

Changed files (4) +186 -4

lib/node.js +26 -0
……@@ -135,6 +135,32 @@ async function boot () {
135135 //
136136 // loadConfig reads PEER_CONFIG, which is set above, and also applies the PEER_* env overrides —
137137 // 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
138164 const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
139165 const cfg = loadConfig()
140166 // deviceMnemonic is DEVICE-scoped: `coowner-join` mints it at config ROOT so a phone can generate
lib/repair-store-backend.js +67 -0
……@@ -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
39const 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 */
52export 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}
package.json +4 -4
……@@ -1,7 +1,7 @@
11{
22 "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.",
55 "private": true,
66 "type": "module",
77 "main": "lib/node.js",
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "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",
1313 "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()))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1515 "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 @@
3333 "pgh-runtime": "file:../pgh-runtime"
3434 },
3535 "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."
3737 },
3838 "overrides": {
3939 "hypercore": "11.34.0",
test/repair-store-backend.test.mjs +89 -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
29import { repairStoreBackend } from '../lib/repair-store-backend.js'
30
31let pass = 0
32const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ }
33
34const minted = (did) => ({ did, deviceMnemonic: 'x', signingKey: 'y', members: ['aa'], forceCreator: true })
35const 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.
75ok(repairStoreBackend(null).changed.length === 0, 'a null config is not a crash')
76ok(repairStoreBackend({}).changed.length === 0, 'a config with no dids is not a crash')
77ok(repairStoreBackend({ dids: 'nonsense' }).changed.length === 0, 'a malformed dids is not a crash')
78ok(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
89console.log('\n' + pass + ' assertions passed')