| … | … | @@ -30,14 +30,9 @@ import 'hyper-proto-peer/lib/bare-globals.js' |
| 30 | 30 | import fs from 'bare-fs' |
| 31 | 31 | import process from 'bare-process' |
| 32 | 32 | import { join } from 'bare-path' |
| 33 | | import http from 'bare-http1' |
| 34 | 33 | import Hyperswarm from 'hyperswarm' |
| 35 | 34 | import { FavelaNode } from 'favela-peer/lib/node.js' |
| 36 | 35 | import { SwarmTransport } from 'favela-peer/lib/transport.js' |
| 37 | | import { dispatch } from 'favela-peer/lib/rpc.js' |
| 38 | | import { route } from 'favela-surface/lib/router.js' |
| 39 | | import { Sessions } from 'favela-surface/lib/session.js' |
| 40 | | import { an } from 'anproto' |
| 41 | 36 | import { createUtf8Decoder } from './host/utf8.js' |
| 42 | 37 | // Stamped at build time by scripts/gen-version.mjs — the bundle carries no readable package.json. |
| 43 | 38 | import { VERSION, BUILT_AT } from './version.js' |
| … | … | @@ -64,183 +59,22 @@ let otaHandle = null |
| 64 | 59 | let refreshUpdateState = () => null |
| 65 | 60 | let lastOtaNote = null |
| 66 | 61 | |
| 67 | | /** |
| 68 | | * The in-process daemon client — what `route` is handed instead of a socket client. |
| 69 | | * |
| 70 | | * ⛔ The same `call(cmd, args) -> Promise` shape favela-surface's own `Client` has, so that file |
| 71 | | * never learns which it got. This is not an emulation of the socket: `dispatch` is the layer the |
| 72 | | * socket server itself calls. |
| 73 | | */ |
| 74 | | export function inProcessClient (node) { |
| 75 | | return { |
| 76 | | call: async (cmd, args = {}) => { |
| 77 | | // ⛔ THE WORKLET OWNS THE COMPOSITION, and it has to: favela-peer's own boundary test forbids |
| 78 | | // the daemon importing anything `atproto-`, and hyper-proto-peer's mint reaches |
| 79 | | // atproto-identity. So the atproto ops cannot live in `dispatch` — but they can live HERE, |
| 80 | | // where the two halves are already in one process, and the surface calls them the same way it |
| 81 | | // calls everything else. Neither package learns about the other. |
| 82 | | if (ATPROTO_OPS[cmd]) return ATPROTO_OPS[cmd](node, args) |
| 83 | | return dispatch(node, cmd, args) |
| 84 | | } |
| 85 | | } |
| 86 | | } |
| 87 | 62 | |
| 88 | | // ⛔ MINTING IS NOT SUBMITTING. `mintIdentity` never touches the network — submitting the genesis to |
| 89 | | // the PLC directory is "a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log" and is a separate |
| 90 | | // call for that reason. A mint that cannot reach the directory still yields a working P2P identity, |
| 91 | | // and the op can be re-derived from the mnemonic and submitted later. |
| 63 | // ── the composition, which now lives in favela-host ───────────────────────────────────────────── |
| 92 | 64 | // |
| 93 | | // ⛔ AND IT IS P2P-ONLY: no handle, no PDS. hyper-proto-peer's mint records why — "creating an |
| 94 | | // identity and joining someone's infrastructure are different decisions, and requiring both at once |
| 95 | | // is what made minting undecidable". The handle and service endpoint arrive later as a PLC update. |
| 96 | | const ATPROTO_OPS = { |
| 97 | | 'did-mint': async (node, a) => { |
| 98 | | const { mintIdentity } = await import('hyper-proto-peer/lib/mint.js') |
| 99 | | const r = await mintIdentity({ rootMnemonic: a.mnemonic || null, onStep: () => {} }) |
| 100 | | // ⚠ The root mnemonic comes back and is NOT stored here. hpp's mint is explicit: minting on the |
| 101 | | // device is the one moment the recovery root exists there, so it is RETURNED for the caller to |
| 102 | | // show once and nothing persists it in the config. Its own test asserts that. |
| 103 | | return { did: r.did, mnemonic: r.rootMnemonic, device: r.devicePublicKey } |
| 104 | | }, |
| 105 | | 'did-submit': async (node, a) => { |
| 106 | | const { submitMintedGenesis } = await import('hyper-proto-peer/lib/mint.js') |
| 107 | | return submitMintedGenesis({ did: a.did }) |
| 108 | | } |
| 109 | | } |
| 110 | | |
| 111 | | /** |
| 112 | | * The loopback surface. Split out from `boot` so it can be exercised off-device: given any node, |
| 113 | | * it stands up the real routes over real HTTP with no bare-kit, no worklet and no Android. |
| 114 | | * |
| 115 | | * ⚠ Binds 127.0.0.1 explicitly. bare-http1's `listen(port)` defaults to all interfaces on some |
| 116 | | * platforms, and a favela node answering on a phone's Wi-Fi address is not what anyone asked for. |
| 117 | | */ |
| 118 | | /** |
| 119 | | * Bind `port` if we can, otherwise any free one. |
| 120 | | * |
| 121 | | * ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral |
| 122 | | * port, so the page the user was looking at pointed at a dead server the moment the node came back — |
| 123 | | * which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node |
| 124 | | * HAD restarted and was serving the new bundle; nobody could see it. |
| 125 | | * |
| 126 | | * ⚠ Falls back rather than failing. Another app can hold the remembered port, and refusing to boot over |
| 127 | | * a port number would be a far worse failure than a changed URL. |
| 128 | | */ |
| 129 | | export function listenPreferring (server, port, host = '127.0.0.1') { |
| 130 | | return new Promise((resolve, reject) => { |
| 131 | | const onError = (err) => { |
| 132 | | if (!port) return reject(err) |
| 133 | | server.removeListener('error', onError) |
| 134 | | server.once('error', reject) |
| 135 | | server.listen(0, host, resolve) |
| 136 | | } |
| 137 | | server.once('error', onError) |
| 138 | | server.listen(port, host, () => { server.removeListener('error', onError); resolve() }) |
| 139 | | }) |
| 140 | | } |
| 141 | | |
| 142 | | export async function serveSurface (node, { port = 0, sessions = new Sessions(), appUpdate = null } = {}) { |
| 143 | | const client = inProcessClient(node) |
| 144 | | const server = http.createServer((req, res) => { |
| 145 | | // ⛔ THE EMBEDDER SUPPLIES IT. favela-surface's router reads no environment by design (its own |
| 146 | | // test asserts that), so the shell state reaches the settings page through ctx or not at all. |
| 147 | | // On a desktop nothing supplies it and the section correctly does not render. |
| 148 | | route(req, res, { client, sessions, appUpdate }).catch((err) => { |
| 149 | | res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) |
| 150 | | res.end(String((err && err.message) || err)) |
| 151 | | }) |
| 152 | | }) |
| 153 | | // ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral |
| 154 | | // port, so the page the user was looking at pointed at a dead server the moment the node came back — |
| 155 | | // which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node |
| 156 | | // HAD restarted and was serving the new bundle; nobody could see it. |
| 157 | | // |
| 158 | | // ⚠ Falls back to ephemeral if the remembered port is taken. Another app can hold it, and refusing to |
| 159 | | // boot over a port number would be a far worse failure than a changed URL. |
| 160 | | await listenPreferring(server, port) |
| 161 | | const bound = server.address().port |
| 162 | | return { server, sessions, client, port: bound, url: 'http://127.0.0.1:' + bound } |
| 163 | | } |
| 164 | | |
| 165 | | // The app's own browser identity, on disk beside the node's storage. |
| 65 | // ⛔ MOVED, NOT COPIED. `inProcessClient`, `serveSurface`, `listenPreferring` and `signIn` were |
| 66 | // written here because this was the only place that held both a favela-peer node and favela-surface. |
| 67 | // A desktop had no such place, so the phone could mint a DID and a desktop could not — the parity |
| 68 | // gap. They are platform-neutral and now live in `favela-host`, which both this worklet and the |
| 69 | // desktop entry import. `serveSurface`'s own comment always said so: "On a desktop nothing supplies |
| 70 | // it and the section correctly does not render." |
| 166 | 71 | // |
| 167 | | // ⛔ A browser is a CONTROLLER, not a device. It cannot hold a writer core or verify a chain, so its |
| 168 | | // key never enters the attestation chain — favela-peer's `lib/browsers.js` says exactly this, and it |
| 169 | | // is why attesting one would hand a WebView the power to mint devices. The app authenticates; the |
| 170 | | // daemon writes. |
| 171 | | const BROWSER_KEY = 'browser.key' |
| 172 | | // Where the last bound surface port is remembered, so a restart serves the same URL the WebView holds. |
| 173 | | const PORT_FILE = 'surface-port' |
| 174 | | |
| 175 | | /** |
| 176 | | * Load or mint this app's anproto keypair. |
| 177 | | * |
| 178 | | * ⚠ PERSISTED, and it has to be. A key regenerated each boot would claim the instance on first run |
| 179 | | * and then be a stranger on the second — permanently locked out, because TOFU closes on the first |
| 180 | | * claim and never reopens, not even for the claimant. 0600, and it is the one secret this app keeps |
| 181 | | * that is not the node's own. |
| 182 | | */ |
| 183 | | async function browserKeypair (storage) { |
| 184 | | const p = join(storage, BROWSER_KEY) |
| 185 | | try { |
| 186 | | const kp = fs.readFileSync(p, 'utf8').trim() |
| 187 | | if (kp.length === 132) return kp |
| 188 | | } catch { /* first run */ } |
| 189 | | const kp = await an.gen() |
| 190 | | fs.writeFileSync(p, kp + '\n', { mode: 0o600 }) |
| 191 | | return kp |
| 192 | | } |
| 193 | | |
| 194 | | /** |
| 195 | | * Prove this app's key to the daemon, claiming the instance on first run. |
| 196 | | * |
| 197 | | * ⛔ OVER THE IN-PROCESS CLIENT, never over HTTP — and that is a security property, not tidiness. |
| 198 | | * TOFU accepts the first key to prove itself, and favela-peer's own note says the safety of that |
| 199 | | * "rests ENTIRELY on the surface binding localhost". On Android every app shares loopback, so a |
| 200 | | * claim made over the listener could in principle be raced by anything else on the device that |
| 201 | | * found the port. Claiming through `dispatch` happens before the listener exists and cannot be |
| 202 | | * reached from outside this process at all. |
| 203 | | * |
| 204 | | * The order matters and is not arbitrary: |
| 205 | | * 1. `browser-status` — already known? Then just authenticate. Re-claiming is not possible. |
| 206 | | * 2. revoked? Say so plainly. A revoked identity stays registered, so this is terminal without |
| 207 | | * a human, and pretending otherwise would loop. |
| 208 | | * 3. unclaimed? Claim it. This is the fresh-install case. |
| 209 | | * 4. otherwise someone else holds it, and an invite is the only way in. |
| 210 | | * |
| 211 | | * ⚠ `an.sign` signs a HASH, not the challenge — `_proveKey` compares `an.parse(opened).hash` |
| 212 | | * against `an.hash(challenge)`. Signing the raw string produces a valid signature over the wrong |
| 213 | | * thing, which verifies and then fails the hash check. |
| 214 | | */ |
| 215 | | export async function signIn (node, storage) { |
| 216 | | const keypair = await browserKeypair(storage) |
| 217 | | const publicKey = an.publicKey(keypair) |
| 218 | | const call = (cmd, args = {}) => dispatch(node, cmd, args) |
| 219 | | |
| 220 | | const prove = async () => { |
| 221 | | const { challenge } = await call('browser-challenge') |
| 222 | | const signature = await an.sign(await an.hash(challenge), keypair) |
| 223 | | return { publicKey, signature, challenge } |
| 224 | | } |
| 225 | | |
| 226 | | const st = await call('browser-status', { publicKey }) |
| 227 | | if (st && st.revoked) { |
| 228 | | throw new Error('this app\'s browser identity was revoked — a revoked identity stays registered, ' + |
| 229 | | 'so it needs an invite from the CLI or another signed-in browser') |
| 230 | | } |
| 231 | | if (st && st.known) { |
| 232 | | await call('browser-auth', await prove()) |
| 233 | | return { publicKey, claimed: false } |
| 234 | | } |
| 235 | | |
| 236 | | const { unclaimed } = await call('browser-unclaimed') |
| 237 | | if (!unclaimed) { |
| 238 | | throw new Error('another browser already claimed this node — TOFU closes on the first claim and ' + |
| 239 | | 'never reopens, so this app needs a one-time invite') |
| 240 | | } |
| 241 | | await call('browser-claim', await prove()) |
| 242 | | return { publicKey, claimed: true } |
| 243 | | } |
| 72 | // ⚠ Re-exported because the off-device tests import them from this path, and moving an |
| 73 | // implementation is not a reason to rewrite the tests that pin it. |
| 74 | // ⚠ Imported AND re-exported. `export ... from` alone re-exports without creating local bindings, so |
| 75 | // `boot()` below — which calls serveSurface and signIn — failed with "serveSurface is not defined". |
| 76 | import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } from 'favela-host' |
| 77 | export { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } |
| 244 | 78 | |
| 245 | 79 | // ── boot ────────────────────────────────────────────────────────────────────────────────────── |
| 246 | 80 | |
| … | … | @@ -261,9 +95,16 @@ export async function boot (storage) { |
| 261 | 95 | stage('node ready', { feed: node.key.toString('hex') }) |
| 262 | 96 | |
| 263 | 97 | node.transport = new SwarmTransport(swarm, { overlay: node.overlay }) |
| 264 | | await node.transport.join(node.peer.feed) |
| 265 | | for (const [, feed] of node._feeds) await node.transport.join(feed) |
| 266 | | stage('swarm joined') |
| 98 | // ⛔ ONE call, and NOT a hand-rolled list. This used to join `node.peer.feed` and `node._feeds` |
| 99 | // itself, which is precisely the code favela-peer's index.js replaced — "Three things have needed |
| 100 | // re-joining by hand here and each was forgotten in turn, producing a log that replicates for |
| 101 | // nobody." Two were forgotten here: the NAMESPACE and the ROOMS. |
| 102 | // |
| 103 | // ⚠ It hid because this is the BOOT path only. `_openNames` announces the namespace itself when a |
| 104 | // transport already exists, so a namespace joined while the node runs replicates — and silently |
| 105 | // stops after the next restart, because nothing here joined it again. |
| 106 | const announced = await node.announceAll() |
| 107 | stage('swarm joined', { announced }) |
| 267 | 108 | |
| 268 | 109 | // ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port on Android could be raced |
| 269 | 110 | // by any other app on the device that found it, and claiming through `dispatch` cannot. |