| … | … | @@ -0,0 +1 @@ |
| 1 | node_modules/ |
| … | … | @@ -0,0 +1,77 @@ |
| 1 | #!/usr/bin/env bare | |
| 2 | // favela-host — the desktop entry. `bare bin/favela-host.js`. | |
| 3 | // | |
| 4 | // One process holding a favela-peer node, favela-surface rendering it, and the atproto verbs. This is | |
| 5 | // the same composition the Android worklet runs, PLUS a unix socket — which the worklet cannot have | |
| 6 | // because Android SELinux refuses an unprivileged app a `sock_file`, and a desktop has no such limit. | |
| 7 | // | |
| 8 | // ⛔ THE SOCKET IS SERVED FROM THE SAME COMPOSITION, not from `dispatch`. That is the whole point of | |
| 9 | // `createServer`'s `call` option: `favela-bridge` and the `favela` CLI reach this over the socket and | |
| 10 | // now see exactly the verb set the pages see. A host that served a smaller set over the socket would | |
| 11 | // be two different daemons wearing one name. | |
| 12 | // | |
| 13 | // ⚠ It does NOT replace favela-peer. A headless box that wants only the daemon still runs | |
| 14 | // `favela-peer`, and a surface pointed at a remote daemon still runs `favela-surface` against this | |
| 15 | // socket. This is the third arrangement, not a replacement for either. | |
| 16 | ||
| 17 | import fs from 'bare-fs' | |
| 18 | import os from 'bare-os' | |
| 19 | import net from 'bare-net' | |
| 20 | import process from 'bare-process' | |
| 21 | import { join } from 'bare-path' | |
| 22 | import Hyperswarm from 'hyperswarm' | |
| 23 | import { createServer } from 'favela-peer/lib/rpc.js' | |
| 24 | import { boot, inProcessClient } from '../lib/host.js' | |
| 25 | ||
| 26 | const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela') | |
| 27 | const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock') | |
| 28 | const port = process.env.FAVELA_SURFACE_PORT ? Number(process.env.FAVELA_SURFACE_PORT) : null | |
| 29 | const host = process.env.FAVELA_SURFACE_HOST || '127.0.0.1' | |
| 30 | ||
| 31 | // ⛔ Refuse to start if a daemon already holds the socket. Removing a live socket would leave the | |
| 32 | // running daemon unreachable while this one looked healthy — one process, two owners, no error. | |
| 33 | // Lifted from favela-peer/index.js, which has the same hazard for the same reason. | |
| 34 | function socketIsLive (path) { | |
| 35 | return new Promise((resolve) => { | |
| 36 | const probe = net.connect(path) | |
| 37 | const done = (v) => { try { probe.destroy() } catch {}; resolve(v) } | |
| 38 | probe.on('connect', () => done(true)) | |
| 39 | probe.on('error', () => done(false)) | |
| 40 | setTimeout(() => done(false), 500) | |
| 41 | }) | |
| 42 | } | |
| 43 | ||
| 44 | const say = (msg, data = {}) => console.log(JSON.stringify({ msg, ...data })) | |
| 45 | ||
| 46 | async function main () { | |
| 47 | fs.mkdirSync(storage, { recursive: true }) | |
| 48 | if (fs.existsSync(sock) && await socketIsLive(sock)) { | |
| 49 | console.error('favela-host: a daemon is already listening on ' + sock) | |
| 50 | process.exit(1) | |
| 51 | } | |
| 52 | ||
| 53 | // ⛔ CONSTRUCTED HERE, not inside the host. lib/host.js explains why: importing hyperswarm there | |
| 54 | // duplicated it in the Android bundle, because this package and that app resolve it to different | |
| 55 | // copies on disk. The desktop owns its swarm; the worklet owns its own. | |
| 56 | const swarm = new Hyperswarm() | |
| 57 | const { node, surface } = await boot(storage, { swarm, onStage: say, port, host }) | |
| 58 | ||
| 59 | // The SAME client the pages use — see the header. `inProcessClient` is cheap and stateless, so | |
| 60 | // building a second one rather than reaching into `surface` keeps the dependency one-way. | |
| 61 | const client = inProcessClient(node) | |
| 62 | createServer(node, sock, { call: (cmd, args) => client.call(cmd, args), onError: () => {} }) | |
| 63 | ||
| 64 | say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url }) | |
| 65 | ||
| 66 | const shutdown = async () => { | |
| 67 | try { surface.server.close() } catch {} | |
| 68 | try { await node.close() } catch {} | |
| 69 | try { await swarm.destroy() } catch {} | |
| 70 | try { fs.unlinkSync(sock) } catch {} | |
| 71 | process.exit(0) | |
| 72 | } | |
| 73 | process.on('SIGINT', shutdown) | |
| 74 | process.on('SIGTERM', shutdown) | |
| 75 | } | |
| 76 | ||
| 77 | main().catch((err) => { console.error(err); process.exit(1) }) |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | // favela-host — whoever holds both halves. | |
| 2 | // | |
| 3 | // favela-surface/index.js names the role this package fills: "favela-surface must NOT depend on | |
| 4 | // favela-peer — the boundary test forbids reaching for storage or a swarm, and importing the daemon | |
| 5 | // would drag both in. So the in-process client is built by whoever holds both halves (the worklet), | |
| 6 | // never here." On a handset that is the worklet. On a desktop it was nobody, which is why the phone | |
| 7 | // could mint a DID and a desktop could not. | |
| 8 | // | |
| 9 | // What this exports is the neutral core: bring a node up, serve favela-surface over loopback, and | |
| 10 | // offer the atproto verbs that neither favela-peer nor favela-surface may import. A shell adds its | |
| 11 | // own platform concerns on top — bare-kit IPC and OTA on Android, a unix socket on a desktop. | |
| 12 | ||
| 13 | export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js' | |
| 14 | export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath } from './lib/atproto.js' |
| … | … | @@ -0,0 +1,190 @@ |
| 1 | // The atproto verbs — and the reason they live in a THIRD package rather than either of the two | |
| 2 | // they sit between. | |
| 3 | // | |
| 4 | // ⛔ NEITHER SIDE MAY HOLD THESE. `favela-peer/test/boundary.test.js` forbids the daemon naming | |
| 5 | // `atproto-` at all, from hyper-social #61 §10: "the moment the daemon imports the projection the | |
| 6 | // dependency has been inverted and the recentring has failed". `favela-surface` is a renderer and | |
| 7 | // its own boundary test forbids it reaching for storage, a swarm or a projection. So the composition | |
| 8 | // belongs to whoever holds both halves — which is this package. | |
| 9 | // | |
| 10 | // ⚠ `favela-identity/spec/identity.md` §7.5 says the same thing from the other direction: "the peer | |
| 11 | // performs the op and is the only thing that appends", and the surface "renders the form". This is | |
| 12 | // the seam that lets both stay true while a DID can still be minted from a settings page. | |
| 13 | ||
| 14 | // ⛔ FIRST, before anything that reaches @noble via the atproto stack. Those modules use TextEncoder | |
| 15 | // at MODULE scope and Bare has no such global, so `hyper-proto-peer/lib/mint.js` fails to load with | |
| 16 | // "TextEncoder is not defined" — measured, not assumed. The mint is imported lazily below, so this | |
| 17 | // line runs first; a shim loaded after the fact is loaded too late. hpp's own bin/ files do the same. | |
| 18 | import 'hyper-proto-peer/lib/bare-globals.js' | |
| 19 | import fs from 'bare-fs' | |
| 20 | import { join, dirname } from 'bare-path' | |
| 21 | ||
| 22 | /** | |
| 23 | * Where a node's atproto config lives. | |
| 24 | * | |
| 25 | * ⛔ UNDER THE NODE'S STORAGE, never `$HOME`. `mintIdentity` defaults to | |
| 26 | * `os.homedir() + '/.config/hyper-proto-peer/config.json'` and `writeConfigFile` is a bare | |
| 27 | * `writeFileSync` with no `mkdirSync` in front of it — so on a handset, which has no home directory, | |
| 28 | * the mint derived every key, built the genesis, and threw ENOENT on its LAST line, losing the | |
| 29 | * recovery root it had just generated with no way to ask for it again. | |
| 30 | * | |
| 31 | * Same class as the OTA homedir chain (hyper-social #69 §1), where the message named the path and | |
| 32 | * not the caller and it cost four sideload cycles. favela-peer already made this fix on its own half | |
| 33 | * and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has | |
| 34 | * no home directory... the daemon already knows where its own state goes, so it says." | |
| 35 | */ | |
| 36 | export const atprotoConfigPath = (node) => join(node.storage, 'atproto', 'config.json') | |
| 37 | ||
| 38 | /** Where a minted identity's sealed recovery root goes. */ | |
| 39 | export const recoveryVaultPath = (node, did) => | |
| 40 | join(node.storage, 'atproto', 'recovery', String(did).split(':').pop() + '.vault.json') | |
| 41 | ||
| 42 | /** | |
| 43 | * Where the genesis operation is kept between minting and submitting. | |
| 44 | * | |
| 45 | * ⛔ IT HAS TO BE KEPT SOMEWHERE. `submitMintedGenesis` requires `{ did, signedOp }` and throws | |
| 46 | * without it, hpp's config entry does not record it, and `mintIdentity` returns it once. So a mint | |
| 47 | * whose caller dropped it could never be published — which is exactly what happened: `did-submit` | |
| 48 | * called `submitMintedGenesis({ did })` and could not have worked on any input. | |
| 49 | * | |
| 50 | * ⚠ NOT SECRET. This is the operation that gets posted to a public global directory; the secret that | |
| 51 | * signed it is the root, which lives sealed in the vault. Keeping it in its own file rather than | |
| 52 | * inside hpp's config keeps this package out of a shape it does not own. | |
| 53 | */ | |
| 54 | export const genesisPath = (node, did) => | |
| 55 | join(node.storage, 'atproto', 'genesis', String(did).split(':').pop() + '.json') | |
| 56 | ||
| 57 | const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } } | |
| 58 | ||
| 59 | const writeJson = (p, v) => { | |
| 60 | fs.mkdirSync(dirname(p), { recursive: true }) | |
| 61 | // Written via a temp file and renamed: a record half-written by a process that died at the wrong | |
| 62 | // moment is indistinguishable from a corrupt one. | |
| 63 | const tmp = p + '.tmp' | |
| 64 | fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + '\n') | |
| 65 | fs.renameSync(tmp, p) | |
| 66 | } | |
| 67 | ||
| 68 | export const ATPROTO_OPS = { | |
| 69 | /** | |
| 70 | * Create a new atproto identity, or restore one from a recovery phrase. | |
| 71 | * | |
| 72 | * ⛔ MINTING IS NOT SUBMITTING. This never touches the network: submitting the genesis to the PLC | |
| 73 | * directory is "a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log" and is a separate verb | |
| 74 | * for that reason. A mint that cannot reach the directory still yields a working P2P identity. | |
| 75 | * | |
| 76 | * ⛔ AND IT IS P2P-ONLY: no handle, no PDS. hyper-proto-peer's mint records why — "creating an | |
| 77 | * identity and joining someone's infrastructure are different decisions, and requiring both at | |
| 78 | * once is what made minting undecidable". Both arrive later as a PLC update. | |
| 79 | */ | |
| 80 | 'did-mint': async (node, a) => { | |
| 81 | // ⛔ REFUSE BEFORE MINTING, NOT AFTER. The passphrase is the only thing protecting the recovery | |
| 82 | // vault, and favela-android's mint records why: the sealed blob is designed to be replicated, so | |
| 83 | // it will land on disks we do not control and is safe there only because it is useless without | |
| 84 | // something in a person's head. Checking afterwards would leave an identity that exists and | |
| 85 | // cannot be protected — and minting is impossible to undo. | |
| 86 | const passphrase = typeof a.passphrase === 'string' ? a.passphrase : '' | |
| 87 | if (passphrase.length < 8) { | |
| 88 | throw new Error('did-mint needs a passphrase of at least 8 characters — it is the only thing protecting your recovery vault') | |
| 89 | } | |
| 90 | ||
| 91 | const { mintIdentity } = await import('hyper-proto-peer/lib/mint.js') | |
| 92 | const configFile = atprotoConfigPath(node) | |
| 93 | // ⚠ Created HERE. `writeConfigFile` opens the path directly, so an absent directory is an ENOENT | |
| 94 | // at the end of an expensive, unrepeatable operation rather than a cheap failure at the start. | |
| 95 | fs.mkdirSync(dirname(configFile), { recursive: true }) | |
| 96 | const r = await mintIdentity({ rootMnemonic: a.mnemonic || null, configFile, onStep: () => {} }) | |
| 97 | ||
| 98 | // ── SEAL IMMEDIATELY ─────────────────────────────────────────────────────────────────────── | |
| 99 | // The root mnemonic exists in memory exactly once, here — hpp's mint returns it and persists it | |
| 100 | // nowhere. Sealing happens before this function returns, so nothing between the mint and the page | |
| 101 | // that shows the words can cost the user their recovery secret. | |
| 102 | // | |
| 103 | // ⚠ LOUD, NOT FATAL. The identity already exists and the phrase is about to be shown; a caller | |
| 104 | // who writes it down has lost nothing. Throwing here would destroy a usable identity over a | |
| 105 | // failure to back it up, so the outcome is REPORTED and the page decides what to say. | |
| 106 | let sealed = false | |
| 107 | let sealError = null | |
| 108 | try { | |
| 109 | const { sealVault } = await import('atproto-identity/lib/vault.js') | |
| 110 | const envelope = sealVault({ secret: r.rootMnemonic, passphrase, did: r.did }) | |
| 111 | const vaultPath = recoveryVaultPath(node, r.did) | |
| 112 | fs.mkdirSync(dirname(vaultPath), { recursive: true }) | |
| 113 | fs.writeFileSync(vaultPath, JSON.stringify(envelope, null, 2) + '\n') | |
| 114 | sealed = true | |
| 115 | } catch (e) { | |
| 116 | sealError = (e && e.message) || String(e) | |
| 117 | } | |
| 118 | ||
| 119 | // ⚠ The root mnemonic comes back and is NOT stored in the clear. Minting on the device is the one | |
| 120 | // moment it exists there, so it is RETURNED for the caller to show once, and what lands on disk | |
| 121 | // is the sealed vault above. | |
| 122 | // | |
| 123 | // ⛔ `signedOp` is carried too. Submitting the genesis needs it — `submitMintedGenesis` requires | |
| 124 | // { did, signedOp } and throws without it — and the config entry does not keep it, so dropping it | |
| 125 | // here is what makes a later submit impossible. | |
| 126 | // ⛔ KEPT, or this identity could never be published. See `genesisPath`. | |
| 127 | let kept = false | |
| 128 | try { writeJson(genesisPath(node, r.did), { did: r.did, signedOp: r.signedOp, submittedAt: null }); kept = true } catch { /* reported below */ } | |
| 129 | ||
| 130 | return { did: r.did, mnemonic: r.rootMnemonic, device: r.devicePublicKey, signedOp: r.signedOp, sealed, sealError, kept } | |
| 131 | }, | |
| 132 | ||
| 133 | /** | |
| 134 | * What this node's atproto identity is, if it has one. | |
| 135 | * | |
| 136 | * ⛔ REPORTS STATE, NEVER A VERDICT. hyper-social #69 §3 earned this rule four times over: "a page | |
| 137 | * that prints a conclusion cannot be used to discover the conclusion is wrong". So this says what | |
| 138 | * is on disk — a DID, a sealed vault, a submission — and the page decides what that means. | |
| 139 | * | |
| 140 | * ⚠ `submitted` is what WE recorded, not what the directory says. Asking the PLC directory would | |
| 141 | * be a network call on every settings load, and a node that is offline would report "not | |
| 142 | * published" about an identity that is. The distinction is the page's to explain. | |
| 143 | */ | |
| 144 | 'did-status': async (node) => { | |
| 145 | const cfg = readJson(atprotoConfigPath(node)) | |
| 146 | const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null | |
| 147 | if (!entry || !entry.did) return { did: null, sealed: false, submitted: false, publishable: false } | |
| 148 | const genesis = readJson(genesisPath(node, entry.did)) | |
| 149 | return { | |
| 150 | did: entry.did, | |
| 151 | sealed: fs.existsSync(recoveryVaultPath(node, entry.did)), | |
| 152 | submitted: !!(genesis && genesis.submittedAt), | |
| 153 | // Whether a submit is even possible — without the genesis operation it is not, and saying so | |
| 154 | // is better than a button that throws. | |
| 155 | publishable: !!(genesis && genesis.signedOp) | |
| 156 | } | |
| 157 | }, | |
| 158 | ||
| 159 | /** | |
| 160 | * ⛔ THE IRREVERSIBLE ONE. Appends the genesis to the global PLC log — no delete, no edit, and a | |
| 161 | * test identity is forever. Its own separateness is the safeguard: nothing that mints can submit | |
| 162 | * by accident. | |
| 163 | */ | |
| 164 | 'did-submit': async (node) => { | |
| 165 | const cfg = readJson(atprotoConfigPath(node)) | |
| 166 | const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null | |
| 167 | if (!entry || !entry.did) throw new Error('did-submit: this node has no identity to publish') | |
| 168 | ||
| 169 | const p = genesisPath(node, entry.did) | |
| 170 | const genesis = readJson(p) | |
| 171 | if (!genesis || !genesis.signedOp) { | |
| 172 | throw new Error('did-submit: the genesis operation for ' + entry.did + ' is not on disk, so it ' + | |
| 173 | 'cannot be published from here — it is re-derivable from the recovery phrase') | |
| 174 | } | |
| 175 | // ⚠ Idempotent by refusal rather than by re-posting. The directory is append-only; a second | |
| 176 | // submission of the same genesis is at best wasted and at worst confusing to read later. | |
| 177 | if (genesis.submittedAt) return { did: entry.did, submitted: true, already: true } | |
| 178 | ||
| 179 | const { submitMintedGenesis } = await import('hyper-proto-peer/lib/mint.js') | |
| 180 | const r = await submitMintedGenesis({ did: entry.did, signedOp: genesis.signedOp }) | |
| 181 | ||
| 182 | // ⛔ RECORDED ONLY AFTER IT SUCCEEDED. Marking first and posting second would leave a node that | |
| 183 | // believes it published something it did not, and the repair for that is a human reading a | |
| 184 | // global log. | |
| 185 | writeJson(p, { ...genesis, submittedAt: Date.now() }) | |
| 186 | return { did: entry.did, submitted: true, already: false, alsoKnownAs: r.alsoKnownAs || [] } | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath } |
| … | … | @@ -0,0 +1,208 @@ |
| 1 | // The host — a favela-peer node with favela-surface rendering it, in one process. | |
| 2 | // | |
| 3 | // ⛔ ONE PROCESS IS THE SHAPE, and a socket is an ADDITION to it rather than the thing that makes it | |
| 4 | // work. `favela-peer/lib/rpc.js` exports `dispatch(node, cmd, a)` next to `createServer(node, sock)`: | |
| 5 | // `dispatch` IS the interface and the socket is one adapter over it. On Android there is no socket to | |
| 6 | // adapt onto — SELinux refuses an unprivileged app a `sock_file` — so the worklet calls the function. | |
| 7 | // On a desktop both are available, so a host can serve pages in-process AND keep the socket for | |
| 8 | // `favela-bridge` and the `favela` CLI. Nothing loses a path by this being one process. | |
| 9 | // | |
| 10 | // ⚠ Everything here is platform-neutral on purpose. What is NOT here: bare-kit IPC, OTA, and the | |
| 11 | // shell handoff — those belong to whichever shell is embedding this. | |
| 12 | ||
| 13 | import fs from 'bare-fs' | |
| 14 | import http from 'bare-http1' | |
| 15 | import { join } from 'bare-path' | |
| 16 | import { FavelaNode } from 'favela-peer/lib/node.js' | |
| 17 | import { SwarmTransport } from 'favela-peer/lib/transport.js' | |
| 18 | import { dispatch } from 'favela-peer/lib/rpc.js' | |
| 19 | import { route } from 'favela-surface/lib/router.js' | |
| 20 | import { Sessions } from 'favela-surface/lib/session.js' | |
| 21 | import { an } from 'anproto' | |
| 22 | import { ATPROTO_OPS } from './atproto.js' | |
| 23 | ||
| 24 | // The app's own browser identity, on disk beside the node's storage. | |
| 25 | const BROWSER_KEY = 'browser.key' | |
| 26 | // Where the last bound surface port is remembered, so a restart serves the same URL a viewer holds. | |
| 27 | export const PORT_FILE = 'surface-port' | |
| 28 | ||
| 29 | /** | |
| 30 | * The in-process daemon client — what `route` is handed instead of a socket client. | |
| 31 | * | |
| 32 | * ⛔ The same `call(cmd, args) -> Promise` shape favela-surface's own `Client` has, so that file | |
| 33 | * never learns which it got. This is not an emulation of the socket: `dispatch` is the layer the | |
| 34 | * socket server itself calls. | |
| 35 | * | |
| 36 | * ⛔ THE HOST OWNS THE COMPOSITION, and it has to: favela-peer's boundary test forbids the daemon | |
| 37 | * importing anything `atproto-`, and hyper-proto-peer's mint reaches atproto-identity. So the | |
| 38 | * atproto ops cannot live in `dispatch` — but they can live here, where both halves are already in | |
| 39 | * one process, and the surface calls them the same way it calls everything else. | |
| 40 | */ | |
| 41 | export function inProcessClient (node) { | |
| 42 | return { | |
| 43 | call: async (cmd, args = {}) => { | |
| 44 | if (ATPROTO_OPS[cmd]) return ATPROTO_OPS[cmd](node, args) | |
| 45 | return dispatch(node, cmd, args) | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Bind `port` if we can, otherwise any free one. | |
| 52 | * | |
| 53 | * ⛔ A STABLE PORT, BECAUSE A VIEWER HOLDS THE URL. Every restart used to bind a fresh ephemeral | |
| 54 | * port, so the page someone was looking at pointed at a dead server the moment the node came back — | |
| 55 | * which is why "restart to apply" looked like it did nothing and the update banner appeared frozen. | |
| 56 | * The node HAD restarted and was serving the new bundle; nobody could see it. | |
| 57 | * | |
| 58 | * ⚠ Falls back rather than failing. Another app can hold the remembered port, and refusing to boot | |
| 59 | * over a port number would be a far worse failure than a changed URL. | |
| 60 | */ | |
| 61 | export function listenPreferring (server, port, host = '127.0.0.1') { | |
| 62 | return new Promise((resolve, reject) => { | |
| 63 | const onError = (err) => { | |
| 64 | if (!port) return reject(err) | |
| 65 | server.removeListener('error', onError) | |
| 66 | server.once('error', reject) | |
| 67 | server.listen(0, host, resolve) | |
| 68 | } | |
| 69 | server.once('error', onError) | |
| 70 | server.listen(port, host, () => { server.removeListener('error', onError); resolve() }) | |
| 71 | }) | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Stand up the real routes over real HTTP, given any node. | |
| 76 | * | |
| 77 | * ⚠ Binds 127.0.0.1. This is your own daemon's data with NO authentication in front of it — exposing | |
| 78 | * it must be a decision, never a default. | |
| 79 | */ | |
| 80 | export async function serveSurface (node, { port = 0, sessions = new Sessions(), appUpdate = null, host = '127.0.0.1' } = {}) { | |
| 81 | const client = inProcessClient(node) | |
| 82 | const server = http.createServer((req, res) => { | |
| 83 | // ⛔ THE EMBEDDER SUPPLIES IT. favela-surface's router reads no environment by design (its own | |
| 84 | // test asserts that), so shell state reaches the settings page through ctx or not at all. A | |
| 85 | // desktop host supplies none and the section correctly does not render. | |
| 86 | route(req, res, { client, sessions, appUpdate }).catch((err) => { | |
| 87 | res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) | |
| 88 | res.end(String((err && err.message) || err)) | |
| 89 | }) | |
| 90 | }) | |
| 91 | await listenPreferring(server, port, host) | |
| 92 | const bound = server.address().port | |
| 93 | return { server, sessions, client, port: bound, url: 'http://' + host + ':' + bound } | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Load or mint this app's anproto keypair. | |
| 98 | * | |
| 99 | * ⚠ PERSISTED, and it has to be. A key regenerated each boot would claim the instance on first run | |
| 100 | * and then be a stranger on the second — permanently locked out, because TOFU closes on the first | |
| 101 | * claim and never reopens, not even for the claimant. 0600, and it is the one secret this app keeps | |
| 102 | * that is not the node's own. | |
| 103 | */ | |
| 104 | async function browserKeypair (storage) { | |
| 105 | const p = join(storage, BROWSER_KEY) | |
| 106 | try { | |
| 107 | const kp = fs.readFileSync(p, 'utf8').trim() | |
| 108 | if (kp.length === 132) return kp | |
| 109 | } catch { /* first run */ } | |
| 110 | const kp = await an.gen() | |
| 111 | fs.writeFileSync(p, kp + '\n', { mode: 0o600 }) | |
| 112 | return kp | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * Prove this app's key to the daemon, claiming the instance on first run. | |
| 117 | * | |
| 118 | * ⛔ OVER THE IN-PROCESS CLIENT, never over HTTP — and that is a security property, not tidiness. | |
| 119 | * TOFU accepts the first key to prove itself, and favela-peer's own note says the safety of that | |
| 120 | * "rests ENTIRELY on the surface binding localhost". On Android every app shares loopback, so a | |
| 121 | * claim made over the listener could in principle be raced by anything else on the device that found | |
| 122 | * the port. Claiming through `dispatch` happens before the listener exists and cannot be reached | |
| 123 | * from outside this process at all. | |
| 124 | * | |
| 125 | * ⚠ `an.sign` signs a HASH, not the challenge — signing the raw string produces a valid signature | |
| 126 | * over the wrong thing, which verifies and then fails the hash check. | |
| 127 | */ | |
| 128 | export async function signIn (node, storage) { | |
| 129 | const keypair = await browserKeypair(storage) | |
| 130 | const publicKey = an.publicKey(keypair) | |
| 131 | const call = (cmd, args = {}) => dispatch(node, cmd, args) | |
| 132 | ||
| 133 | const prove = async () => { | |
| 134 | const { challenge } = await call('browser-challenge') | |
| 135 | const signature = await an.sign(await an.hash(challenge), keypair) | |
| 136 | return { publicKey, signature, challenge } | |
| 137 | } | |
| 138 | ||
| 139 | const st = await call('browser-status', { publicKey }) | |
| 140 | if (st && st.revoked) { | |
| 141 | throw new Error('this app\'s browser identity was revoked — a revoked identity stays registered, ' + | |
| 142 | 'so it needs an invite from the CLI or another signed-in browser') | |
| 143 | } | |
| 144 | if (st && st.known) { | |
| 145 | await call('browser-auth', await prove()) | |
| 146 | return { publicKey, claimed: false } | |
| 147 | } | |
| 148 | ||
| 149 | const { unclaimed } = await call('browser-unclaimed') | |
| 150 | if (!unclaimed) { | |
| 151 | throw new Error('another browser already claimed this node — TOFU closes on the first claim and ' + | |
| 152 | 'never reopens, so this app needs a one-time invite') | |
| 153 | } | |
| 154 | await call('browser-claim', await prove()) | |
| 155 | return { publicKey, claimed: true } | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Bring a node up and serve it. | |
| 160 | * | |
| 161 | * ⛔ THE CALLER OWNS THE SWARM, and that is not only injection-for-testing. bare-pack bundles by | |
| 162 | * physical path, and this package resolves `hyperswarm` to a DIFFERENT copy on disk than the Android | |
| 163 | * app does — so importing it here put hyperswarm, hyperdht, udx-native and sodium into the worklet | |
| 164 | * bundle TWICE. Measured: 31.8 MB → 40.1 MB, +26%, for code that was already in there. A shared | |
| 165 | * package taking a heavy transport as an argument costs nothing and cannot duplicate it. | |
| 166 | * | |
| 167 | * `onStage` reports progress to whatever is embedding this — bare-kit IPC on a handset, a log line | |
| 168 | * on a desktop. The host does not know which. | |
| 169 | */ | |
| 170 | export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1' } = {}) { | |
| 171 | if (!swarm) throw new Error('boot needs a swarm — the caller constructs it, see the note above') | |
| 172 | fs.mkdirSync(storage, { recursive: true }) | |
| 173 | onStage('storage', { storage }) | |
| 174 | ||
| 175 | const node = new FavelaNode(storage, { transport: null, swarm }) | |
| 176 | // ⛔ ready() FIRST: it builds the overlay, and the transport needs it — the bytes ride the same | |
| 177 | // connections as the log, so a transport without the overlay replicates half the system. Same | |
| 178 | // order favela-peer's own index.js uses, for the same reason. | |
| 179 | await node.ready() | |
| 180 | onStage('node ready', { feed: node.key.toString('hex') }) | |
| 181 | ||
| 182 | node.transport = new SwarmTransport(swarm, { overlay: node.overlay }) | |
| 183 | // ⛔ ONE call, and NOT a hand-rolled list. The worklet used to join `node.peer.feed` and | |
| 184 | // `node._feeds` itself — precisely the code favela-peer's index.js replaced, and for the reason its | |
| 185 | // comment gives: "Three things have needed re-joining by hand here and each was forgotten in turn, | |
| 186 | // producing a log that replicates for nobody." Two WERE forgotten: the namespace and the rooms. | |
| 187 | const announced = await node.announceAll() | |
| 188 | onStage('swarm joined', { announced }) | |
| 189 | ||
| 190 | // ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port could be raced by anything | |
| 191 | // else that found it; claiming through `dispatch` cannot. | |
| 192 | const who = await signIn(node, storage) | |
| 193 | onStage(who.claimed ? 'claimed this node' : 'signed in') | |
| 194 | ||
| 195 | // Reuse the port bound last time so a viewer's URL survives a restart. | |
| 196 | let wanted = port | |
| 197 | if (wanted === null) { | |
| 198 | try { wanted = Number(String(fs.readFileSync(join(storage, PORT_FILE), 'utf8')).trim()) || 0 } catch { wanted = 0 } | |
| 199 | } | |
| 200 | const surface = await serveSurface(node, { port: wanted, appUpdate, host }) | |
| 201 | try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ } | |
| 202 | onStage('serving', { url: surface.url }) | |
| 203 | ||
| 204 | return { node, swarm, surface, who } | |
| 205 | } | |
| 206 | ||
| 207 | export { ATPROTO_OPS } | |
| 208 | export default { boot, serveSurface, inProcessClient, listenPreferring, signIn } |
| … | … | @@ -0,0 +1,30 @@ |
| 1 | { | |
| 2 | "name": "favela-host", | |
| 3 | "version": "0.0.1", | |
| 4 | "description": "Holds both halves — a favela-peer node, favela-surface rendering it, and the atproto verbs neither package may import.", | |
| 5 | "type": "module", | |
| 6 | "main": "index.js", | |
| 7 | "bin": { | |
| 8 | "favela-host": "bin/favela-host.js" | |
| 9 | }, | |
| 10 | "scripts": { | |
| 11 | "test": "brittle-bare test/*.test.js" | |
| 12 | }, | |
| 13 | "dependencies": { | |
| 14 | "anproto": "file:../anproto", | |
| 15 | "atproto-identity": "file:../atproto-identity", | |
| 16 | "bare-fs": "^4.8.1", | |
| 17 | "bare-http1": "^4.5.7", | |
| 18 | "bare-net": "^2.3.3", | |
| 19 | "bare-os": "^3.9.3", | |
| 20 | "bare-path": "^3.1.2", | |
| 21 | "bare-process": "^4.5.1", | |
| 22 | "favela-peer": "file:../favela-peer", | |
| 23 | "favela-surface": "file:../favela-surface", | |
| 24 | "hyper-proto-peer": "file:../hyper-proto-peer", | |
| 25 | "hyperswarm": "^4.17.0" | |
| 26 | }, | |
| 27 | "devDependencies": { | |
| 28 | "brittle": "^3.5.0" | |
| 29 | } | |
| 30 | } |
| … | … | @@ -0,0 +1,101 @@ |
| 1 | // What a node can say about its own atproto identity — and what it refuses to do without. | |
| 2 | // | |
| 3 | // ⛔ The settings page cannot render Flow 1 without this. Before it, nothing anywhere could answer | |
| 4 | // "does this node have a DID?", so the page had a text box where a state machine belonged. | |
| 5 | // | |
| 6 | // ⚠ The submit SUCCESS path is not tested here and deliberately so: it appends to the live global PLC | |
| 7 | // directory, which is permanent and public, and a test identity would be forever. What IS tested is | |
| 8 | // every refusal in front of it — which is where the real defect was, since `did-submit` used to call | |
| 9 | // `submitMintedGenesis({ did })` without the `signedOp` it requires and could not have worked at all. | |
| 10 | ||
| 11 | import test from 'brittle' | |
| 12 | import fs from 'bare-fs' | |
| 13 | import os from 'bare-os' | |
| 14 | import process from 'bare-process' | |
| 15 | import { join } from 'bare-path' | |
| 16 | import { inProcessClient, ATPROTO_OPS, genesisPath, recoveryVaultPath } from '../index.js' | |
| 17 | ||
| 18 | const PASS = 'a passphrase long enough' | |
| 19 | ||
| 20 | function fixture (t, name) { | |
| 21 | const root = join(os.tmpdir(), 'favela-host-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2)) | |
| 22 | const home = join(root, 'home') | |
| 23 | const storage = join(root, 'storage') | |
| 24 | fs.mkdirSync(home, { recursive: true }) | |
| 25 | fs.mkdirSync(storage, { recursive: true }) | |
| 26 | // $HOME redirected: a mint that took the desktop default would read — and could write — the real | |
| 27 | // hyper-proto-peer config on this machine, which holds a live identity. | |
| 28 | const realHome = process.env.HOME | |
| 29 | process.env.HOME = home | |
| 30 | t.teardown(() => { process.env.HOME = realHome }) | |
| 31 | return { storage, node: { storage }, client: inProcessClient({ storage }) } | |
| 32 | } | |
| 33 | ||
| 34 | test('a node with no identity says so, rather than failing', async (t) => { | |
| 35 | const { client } = fixture(t, 'empty') | |
| 36 | const st = await client.call('did-status', {}) | |
| 37 | t.is(st.did, null, 'no DID') | |
| 38 | t.absent(st.sealed, 'nothing sealed') | |
| 39 | t.absent(st.submitted, 'nothing submitted') | |
| 40 | t.absent(st.publishable, 'and nothing to publish') | |
| 41 | }) | |
| 42 | ||
| 43 | test('after minting it reports the DID, the vault, and that publishing is possible', async (t) => { | |
| 44 | const { node, client } = fixture(t, 'minted') | |
| 45 | const r = await client.call('did-mint', { passphrase: PASS }) | |
| 46 | ||
| 47 | const st = await client.call('did-status', {}) | |
| 48 | t.is(st.did, r.did, 'the DID it minted') | |
| 49 | t.ok(st.sealed, 'the recovery vault is on disk') | |
| 50 | t.ok(fs.existsSync(recoveryVaultPath(node, r.did)), 'where did-status says it is') | |
| 51 | t.ok(st.publishable, 'the genesis operation was kept, so it CAN be published') | |
| 52 | t.absent(st.submitted, 'and has not been — that is a separate, irreversible act') | |
| 53 | }) | |
| 54 | ||
| 55 | test('the genesis is kept on disk, because nothing else keeps it', async (t) => { | |
| 56 | const { node, client } = fixture(t, 'genesis') | |
| 57 | const r = await client.call('did-mint', { passphrase: PASS }) | |
| 58 | ||
| 59 | t.ok(r.kept, 'the mint reports keeping it') | |
| 60 | const g = JSON.parse(fs.readFileSync(genesisPath(node, r.did), 'utf8')) | |
| 61 | t.is(g.did, r.did) | |
| 62 | t.ok(g.signedOp, 'the operation submitMintedGenesis requires') | |
| 63 | t.is(g.submittedAt, null, 'unsubmitted') | |
| 64 | ||
| 65 | // hpp's own config does NOT carry it — which is why this file exists at all. | |
| 66 | const cfg = JSON.parse(fs.readFileSync(join(node.storage, 'atproto', 'config.json'), 'utf8')) | |
| 67 | t.absent(cfg.dids[0].signedOp, 'hpp keeps no copy, so dropping ours would make publishing impossible') | |
| 68 | }) | |
| 69 | ||
| 70 | test('publishing refuses without an identity, and without a genesis to publish', async (t) => { | |
| 71 | const { node, client } = fixture(t, 'refusals') | |
| 72 | ||
| 73 | await t.exception(() => client.call('did-submit', {}), /no identity/) | |
| 74 | ||
| 75 | const r = await client.call('did-mint', { passphrase: PASS }) | |
| 76 | // Remove the genesis to reach the second refusal. A real node loses it only by damage, and the | |
| 77 | // message has to say what the way back is rather than throwing a missing-field error from inside. | |
| 78 | fs.unlinkSync(genesisPath(node, r.did)) | |
| 79 | await t.exception(() => client.call('did-submit', {}), /not on disk/) | |
| 80 | }) | |
| 81 | ||
| 82 | test('an already-submitted identity is not posted twice', async (t) => { | |
| 83 | const { node, client } = fixture(t, 'already') | |
| 84 | const r = await client.call('did-mint', { passphrase: PASS }) | |
| 85 | ||
| 86 | // Stand in for a successful submission. The directory is append-only, so a second post of the same | |
| 87 | // genesis is at best waste — and reaching the network to prove that would be the very thing this | |
| 88 | // test must not do. | |
| 89 | const p = genesisPath(node, r.did) | |
| 90 | const g = JSON.parse(fs.readFileSync(p, 'utf8')) | |
| 91 | fs.writeFileSync(p, JSON.stringify({ ...g, submittedAt: Date.now() }, null, 2) + '\n') | |
| 92 | ||
| 93 | const out = await client.call('did-submit', {}) | |
| 94 | t.ok(out.already, 'it reports the identity was already published') | |
| 95 | t.ok(out.submitted) | |
| 96 | t.ok((await client.call('did-status', {})).submitted, 'and status agrees') | |
| 97 | }) | |
| 98 | ||
| 99 | test('the verb set is exactly what the pages expect', (t) => { | |
| 100 | t.alike(Object.keys(ATPROTO_OPS).sort(), ['did-mint', 'did-status', 'did-submit']) | |
| 101 | }) |