| … | … | @@ -11,4 +11,4 @@ |
| 11 | 11 | // own platform concerns on top — bare-kit IPC and OTA on Android, a unix socket on a desktop. |
| 12 | 12 | |
| 13 | 13 | export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js' |
| 14 | export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath } from './lib/atproto.js' | |
| 14 | export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath, bootAtproto } from './lib/atproto.js' |
| … | … | @@ -188,3 +188,63 @@ export const ATPROTO_OPS = { |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | 190 | export 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 | */ | |
| 210 | export 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 | } |
| … | … | @@ -19,7 +19,7 @@ import { dispatch } from 'favela-peer/lib/rpc.js' |
| 19 | 19 | import { route } from 'favela-surface/lib/router.js' |
| 20 | 20 | import { Sessions } from 'favela-surface/lib/session.js' |
| 21 | 21 | import { an } from 'anproto' |
| 22 | import { ATPROTO_OPS } from './atproto.js' | |
| 22 | import { ATPROTO_OPS, bootAtproto } from './atproto.js' | |
| 23 | 23 | |
| 24 | 24 | // The app's own browser identity, on disk beside the node's storage. |
| 25 | 25 | const BROWSER_KEY = 'browser.key' |
| … | … | @@ -201,7 +201,22 @@ export async function boot (storage, { swarm, onStage = () => {}, appUpdate = nu |
| 201 | 201 | try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ } |
| 202 | 202 | onStage('serving', { url: surface.url }) |
| 203 | 203 | |
| 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 } | |
| 205 | 220 | } |
| 206 | 221 | |
| 207 | 222 | export { ATPROTO_OPS } |
| … | … | @@ -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 | ||
| 9 | import test from 'brittle' | |
| 10 | import fs from 'bare-fs' | |
| 11 | import os from 'bare-os' | |
| 12 | import process from 'bare-process' | |
| 13 | import { join } from 'bare-path' | |
| 14 | import { inProcessClient, bootAtproto, atprotoConfigPath } from '../index.js' | |
| 15 | ||
| 16 | const PASS = 'a passphrase long enough' | |
| 17 | ||
| 18 | function 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 | ||
| 38 | test('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 | ||
| 43 | test('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 | ||
| 67 | test('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 | }) |