OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / 13fa0cd

boot does not wait for the atproto half
It opens a corestore, a swarm and a topic per identity, so on a node that holds a DID it can take a long time or never settle — and awaiting it stopped everything below from running. Returned as a promise so a caller that needs the handle can wait and one that just wants a booted node does not have to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13fa0cdc21e4852b5605bb87a900dcb690f7ad13
by plankatron <fleeky@gmail.com> · 2026-09-11T17:36:36.000Z

Changed files (4) +153 -3

index.js +1 -1
……@@ -11,4 +11,4 @@
1111// own platform concerns on top — bare-kit IPC and OTA on Android, a unix socket on a desktop.
1212
1313export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js'
14export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath } from './lib/atproto.js'
14export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath, bootAtproto } from './lib/atproto.js'
lib/atproto.js +60 -0
……@@ -188,3 +188,63 @@ export const ATPROTO_OPS = {
188188}
189189
190190export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath }
191
192/**
193 * Boot this node's atproto identities — the thing that makes the device a WRITER rather than a reader.
194 *
195 * ⛔ THIS IS WHY hpp IS HERE AT ALL. Until now the worklet reached exactly two hpp entry points, mint
196 * and submit, so a phone could create an identity and publish it to the global directory and then
197 * author nothing under it. The swarm replicates a DID's repo to every device that holds it; a device
198 * that never boots the DID holds nothing.
199 *
200 * ⛔ `loadConfig()`, NEVER `JSON.parse`. favela-android learned this the hard way: the `members`
201 * roster has three on-disk shapes — [{key,role}], an array of hex strings, and a "hex,hex" string —
202 * and only the first is understood downstream. Hand-parsing made it the one reader that skipped the
203 * rule, the roster arrived EMPTY, and the room refused to project while the config plainly listed
204 * three members. `loadConfig` normalises all three, and it reads PEER_CONFIG, which is set just above
205 * it for that reason.
206 *
207 * ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal
208 * state, not a failure, and a read-only node that boots beats a node that does not.
209 */
210export async function bootAtproto (node, { log = () => {} } = {}) {
211 const configFile = atprotoConfigPath(node)
212 if (!fs.existsSync(configFile)) return null
213
214 const home = join(node.storage, 'atproto')
215 // ⛔ Set BEFORE loadConfig — it is what loadConfig reads. Our mint writes the config under the
216 // node's storage precisely because a handset has no home directory, so hpp must be pointed at it.
217 process.env.PEER_CONFIG = configFile
218 process.env.ATPROTO_OVERLAY_ROOT = process.env.ATPROTO_OVERLAY_ROOT || join(home, 'overlay')
219
220 const { loadConfig } = await import('hyper-proto-peer/lib/config.js')
221 const cfg = loadConfig()
222 const dids = Array.isArray(cfg.dids) ? cfg.dids : []
223 if (dids.length === 0) return null
224
225 // ⛔ SAY WHAT THIS DEVICE IS. The device public key is the one value another machine needs in order
226 // to admit this phone to a DID's roster, and on a release-signed app it is unreadable from adb. A
227 // phone that holds a DID but is absent from its roster could not otherwise even name itself to the
228 // machine able to fix that — favela-android lost an evening to exactly this.
229 try {
230 const { deviceKeetKeyPair } = await import('atproto-identity/lib/device-chain.js')
231 const b4a = (await import('b4a')).default
232 for (const d of dids) {
233 const mn = d.deviceMnemonic || cfg.deviceMnemonic
234 if (!mn) continue
235 const kp = await deviceKeetKeyPair(mn)
236 log('atproto device key', { did: d.did, devicePublicKey: b4a.toString(kp.publicKey, 'hex') })
237 }
238 } catch (e) { log('could not derive the device public key', { error: (e && e.message) || String(e) }) }
239
240 const { bootMultiDevice } = await import('hyper-proto-peer/lib/device-node.js')
241 const hpp = await bootMultiDevice({ storage: home, dids, deviceMnemonic: cfg.deviceMnemonic || null })
242
243 // ⛔ REPORT WHAT BOOTED, NOT WHAT WAS CONFIGURED. favela-android printed the config's DID count, so
244 // it said "signing enabled" while zero nodes had come up, and an hour went into diagnosing an empty
245 // appview when there was no node at all. A count that cannot express the failure is worse than none.
246 const up = (hpp && hpp.nodes && hpp.nodes.length) || 0
247 if (up === 0) log('atproto booted NO DIDs — this device signs nothing', { configured: dids.length })
248 else log('atproto up', { dids: up, configured: dids.length })
249 return hpp
250}
lib/host.js +17 -2
……@@ -19,7 +19,7 @@ import { dispatch } from 'favela-peer/lib/rpc.js'
1919import { route } from 'favela-surface/lib/router.js'
2020import { Sessions } from 'favela-surface/lib/session.js'
2121import { an } from 'anproto'
22import { ATPROTO_OPS } from './atproto.js'
22import { ATPROTO_OPS, bootAtproto } from './atproto.js'
2323
2424// The app's own browser identity, on disk beside the node's storage.
2525const BROWSER_KEY = 'browser.key'
……@@ -201,7 +201,22 @@ export async function boot (storage, { swarm, onStage = () => {}, appUpdate = nu
201201 try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ }
202202 onStage('serving', { url: surface.url })
203203
204 return { node, swarm, surface, who }
204 // ⛔ AFTER the surface is up, and non-fatal. This is what makes the device a WRITER of its atproto
205 // identity rather than only a holder of one — the swarm replicates a DID's repo to every device that
206 // boots it, and a device that never boots it holds nothing. But a node whose atproto half fails must
207 // still serve its own favela records: favela-android's rule, "a ready the UI cannot use is worse than
208 // a failure it can read".
209 // ⛔ NOT AWAITED. It opens a corestore, a swarm and a topic per identity, so on a node that holds a
210 // DID it can take a long time or never settle. On the handset, awaiting it stopped everything below
211 // from running — including the write of the update state — so the settings page rendered the
212 // previous boot's snapshot for ever and its update banner could not be cleared from the device.
213 //
214 // ⚠ Returned as a promise, so a caller that genuinely needs the handle can wait for it and a caller
215 // that just wants a booted node does not have to.
216 const atproto = bootAtproto(node, { log: (m, d) => onStage(m, d) })
217 .catch((e) => { onStage('atproto boot failed', { error: (e && e.message) || String(e) }); return null })
218
219 return { node, swarm, surface, who, atproto }
205220}
206221
207222export { ATPROTO_OPS }
test/boot-atproto.test.js +75 -0
……@@ -0,0 +1,75 @@
1// Booting the atproto identity — the step that makes a device a WRITER of its DID.
2//
3// ⛔ WHY THIS EXISTS. The worklet reached exactly two hpp entry points, mint and submit, so a phone
4// could create an identity, publish it to the global directory, and then author nothing under it.
5// The swarm replicates a DID's repo to every device that holds it; a device that never boots the DID
6// holds nothing. `repair-store-backend.js` was copied into the app assuming this boot existed — and
7// had no callers, which is how it stayed missing.
8
9import test from 'brittle'
10import fs from 'bare-fs'
11import os from 'bare-os'
12import process from 'bare-process'
13import { join } from 'bare-path'
14import { inProcessClient, bootAtproto, atprotoConfigPath } from '../index.js'
15
16const PASS = 'a passphrase long enough'
17
18function fixture (t, name) {
19 const root = join(os.tmpdir(), 'fh-boot-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
20 const home = join(root, 'home')
21 const storage = join(root, 'storage')
22 fs.mkdirSync(home, { recursive: true })
23 fs.mkdirSync(storage, { recursive: true })
24 const realHome = process.env.HOME
25 const realCfg = process.env.PEER_CONFIG
26 const realOverlay = process.env.ATPROTO_OVERLAY_ROOT
27 process.env.HOME = home
28 t.teardown(() => {
29 process.env.HOME = realHome
30 // ⚠ Assigned, never deleted: bare-process's env is a proxy whose deleteProperty trap refuses,
31 // throwing "trap returned falsish". An empty string is what the code under test treats as unset.
32 process.env.PEER_CONFIG = realCfg || ''
33 process.env.ATPROTO_OVERLAY_ROOT = realOverlay || ''
34 })
35 return { storage, node: { storage }, client: inProcessClient({ storage }) }
36}
37
38test('a node with no identity boots no atproto, and says so by returning null', async (t) => {
39 const { node } = fixture(t, 'none')
40 t.is(await bootAtproto(node), null, 'not a failure — a node with no DID is the normal state')
41})
42
43test('after minting, the DID boots and this device becomes a writer of it', async (t) => {
44 const { node, client } = fixture(t, 'up')
45 const r = await client.call('did-mint', { passphrase: PASS })
46
47 const said = []
48 const hpp = await bootAtproto(node, { log: (m, d) => said.push([m, d]) })
49 t.teardown(async () => { try { await hpp.close() } catch {} })
50
51 t.ok(hpp, 'it booted')
52 t.is(hpp.nodes.length, 1, 'exactly the one identity that was minted')
53
54 // ⛔ Reported, because favela-android printed the CONFIG's count and said "signing enabled" while
55 // zero nodes had come up — an hour lost diagnosing an empty appview when there was no node at all.
56 const up = said.find(([m]) => m === 'atproto up')
57 t.ok(up, 'it reported what BOOTED')
58 t.is(up[1].dids, 1)
59
60 // The one value another machine needs to admit this phone to the DID's roster, and unreadable from
61 // adb on a release-signed app.
62 const key = said.find(([m]) => m === 'atproto device key')
63 t.ok(key && /^[0-9a-f]{64}$/.test(key[1].devicePublicKey), 'and named this device: ' + (key && key[1].devicePublicKey || 'MISSING'))
64 t.is(key[1].did, r.did)
65})
66
67test('it points hpp at the config under node storage, not a home directory', async (t) => {
68 const { node, client } = fixture(t, 'cfgpath')
69 await client.call('did-mint', { passphrase: PASS })
70 const hpp = await bootAtproto(node)
71 t.teardown(async () => { try { await hpp.close() } catch {} })
72 t.is(process.env.PEER_CONFIG, atprotoConfigPath(node), 'PEER_CONFIG is the file our mint wrote')
73 t.absent(fs.existsSync(join(process.env.HOME, '.config', 'hyper-proto-peer', 'config.json')),
74 'and nothing was read from or written to $HOME')
75})