OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / 02e6542

favela-host — whoever holds both halves
favela-surface/index.js names the role this fills: "favela-surface must NOT depend on favela-peer — the boundary test forbids reaching for storage or a swarm, and importing the daemon would drag both in. So the in-process client is built by whoever holds both halves (the worklet), never here." On a handset that is the worklet. On a desktop it was nobody, which is why the phone could mint a DID and a desktop could not. The code that joins the two halves is platform-neutral and was only ever in the Android app by accident of where it was first needed; most of this is moved, not new. It also has to be a THIRD package rather than either of the two it sits between: favela-peer's boundary test forbids the daemon naming atproto- anything, and favela-surface's forbids it depending on favela-peer. The composition has nowhere to live inside either. New here beyond the move: did-status, which reports what is on disk so a page can render a state machine rather than a text box; and keeping the genesis operation, without which did-submit could never have worked — it requires { did, signedOp }, hpp's config records no signedOp, and mintIdentity returns it exactly once. bin/favela-host.js is the desktop entry: the same composition the worklet runs, plus a unix socket the worklet cannot have. It does not replace favela-peer — a headless box still runs the daemon alone, and a surface pointed at a remote daemon still works. ⚠ The caller owns the swarm. bare-pack bundles by physical path and this package resolves hyperswarm to a different copy on disk than the app does, which put it into the worklet bundle twice: measured 31.8 MB -> 40.1 MB for code already there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
02e65423cc50cfe744e0d6d2284d0dc7c0577383
by plankatron <fleeky@gmail.com> · 2026-09-11T15:17:34.000Z

Changed files (7) +621 -0

.gitignore +1 -0
……@@ -0,0 +1 @@
1node_modules/
bin/favela-host.js +77 -0
……@@ -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
17import fs from 'bare-fs'
18import os from 'bare-os'
19import net from 'bare-net'
20import process from 'bare-process'
21import { join } from 'bare-path'
22import Hyperswarm from 'hyperswarm'
23import { createServer } from 'favela-peer/lib/rpc.js'
24import { boot, inProcessClient } from '../lib/host.js'
25
26const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela')
27const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock')
28const port = process.env.FAVELA_SURFACE_PORT ? Number(process.env.FAVELA_SURFACE_PORT) : null
29const 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.
34function 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
44const say = (msg, data = {}) => console.log(JSON.stringify({ msg, ...data }))
45
46async 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
77main().catch((err) => { console.error(err); process.exit(1) })
index.js +14 -0
……@@ -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
13export { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from './lib/host.js'
14export { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath, genesisPath } from './lib/atproto.js'
lib/atproto.js +190 -0
……@@ -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.
18import 'hyper-proto-peer/lib/bare-globals.js'
19import fs from 'bare-fs'
20import { 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 */
36export const atprotoConfigPath = (node) => join(node.storage, 'atproto', 'config.json')
37
38/** Where a minted identity's sealed recovery root goes. */
39export 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 */
54export const genesisPath = (node, did) =>
55 join(node.storage, 'atproto', 'genesis', String(did).split(':').pop() + '.json')
56
57const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
58
59const 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
68export 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
190export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath }
lib/host.js +208 -0
……@@ -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
13import fs from 'bare-fs'
14import http from 'bare-http1'
15import { join } from 'bare-path'
16import { FavelaNode } from 'favela-peer/lib/node.js'
17import { SwarmTransport } from 'favela-peer/lib/transport.js'
18import { dispatch } from 'favela-peer/lib/rpc.js'
19import { route } from 'favela-surface/lib/router.js'
20import { Sessions } from 'favela-surface/lib/session.js'
21import { an } from 'anproto'
22import { ATPROTO_OPS } from './atproto.js'
23
24// The app's own browser identity, on disk beside the node's storage.
25const BROWSER_KEY = 'browser.key'
26// Where the last bound surface port is remembered, so a restart serves the same URL a viewer holds.
27export 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 */
41export 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 */
61export 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 */
80export 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 */
104async 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 */
128export 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 */
170export 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
207export { ATPROTO_OPS }
208export default { boot, serveSurface, inProcessClient, listenPreferring, signIn }
package.json +30 -0
……@@ -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}
test/did-status.test.js +101 -0
……@@ -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
11import test from 'brittle'
12import fs from 'bare-fs'
13import os from 'bare-os'
14import process from 'bare-process'
15import { join } from 'bare-path'
16import { inProcessClient, ATPROTO_OPS, genesisPath, recoveryVaultPath } from '../index.js'
17
18const PASS = 'a passphrase long enough'
19
20function 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
34test('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
43test('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
55test('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
70test('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
82test('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
99test('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})