OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / 192a6fe

README: the repo had none, and it is the seam that fixed desktop parity
favela-host had no README. It is the package that resolved the parity gap — the phone could mint a DID and a desktop could not, because the in-process client has to be built by whoever holds both a favela-peer node and favela-surface, and on a desktop that was nobody. The only statement of that anywhere was a comment in index.js. Written as a reference in the holepunch shape, per favela-markdown 462e960: name, one sentence, Install, API, Test, License. No glyphs. The API section is the bulk, with a signature and a return shape for each of boot, inProcessClient, serveSurface, listenPreferring, signIn, bootAtproto, the six ATPROTO_OPS and lib/project.js. Records the reasons that only existed in source comments, because each one is a constraint a caller can break: - boot takes the swarm rather than importing it: bare-pack bundles by physical path and this package resolves hyperswarm to a different copy than the Android app, which put the transport in the worklet bundle twice (31.8 MB -> 40.1 MB, +26%). - ready() before the transport, because the bytes ride the log's connections. - signIn before the listener: TOFU accepts the first key to prove itself, and on Android every app shares loopback, so a claim over a port could be raced. - bootAtproto is not awaited and returns a promise; awaiting it on the handset stopped the update-state write and froze the settings banner. - did-mint checks the passphrase before minting, since minting cannot be undone. - did-carry is one verb because either half alone leaves an expensive broken state. - deviceClient has no close(), because BskySink closes what it is handed. Test numbers measured: 20/20, 50 asserts, ~31 s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192a6feb371f7da9bd2ae0b3a81ee726f3377be9
by plankatron <fleeky@gmail.com> · 2026-09-20T20:38:06.000Z

Changed files (1) +201 -0

README.md +201 -0
……@@ -0,0 +1,201 @@
1# favela-host
2
3Whoever holds both halves. Brings a `favela-peer` node up, serves `favela-surface` over loopback,
4and offers the atproto verbs that neither of those two may import.
5
6`favela-surface` must not depend on `favela-peer` — its boundary test forbids reaching for storage or
7a swarm, and importing the daemon would drag in both. So the in-process client has to be built by
8something holding both, which on a handset is the bare-kit worklet and on a desktop used to be
9nobody. That is why a phone could mint a DID and a desktop could not.
10
11Platform-neutral. What is deliberately not here: bare-kit IPC, OTA, and the shell handoff — those
12belong to whichever shell is embedding this.
13
14## Install
15
16```sh
17bare bin/favela-host.js
18```
19
20| var | default | |
21|---|---|---|
22| `FAVELA_STORAGE` | `~/.favela` | corestore, overlay, working tree |
23| `FAVELA_SOCK` | `<storage>/favela.sock` | RPC socket |
24| `FAVELA_SURFACE_PORT` | the port bound last run, else any free one | |
25| `FAVELA_SURFACE_HOST` | `127.0.0.1` | no authentication in front of it |
26
27Refuses to start if a daemon already holds the socket. Removing a live socket would leave the running
28daemon unreachable while this one looked healthy — one process, two owners, no error.
29
30This does not replace `favela-peer`. A headless box that wants only the daemon still runs
31`favela-peer`, and a surface pointed at a remote daemon still runs `favela-surface` against this
32socket. It is a third arrangement, not a replacement for either.
33
34The socket is served from the same composition rather than from `dispatch`, using `createServer`'s
35`call` option — so `favela-bridge` and the `favela` CLI see exactly the verb set the pages see. A host
36serving a smaller set over the socket would be two daemons wearing one name.
37
38## API
39
40```js
41import { boot, serveSurface, inProcessClient, listenPreferring, signIn, PORT_FILE } from 'favela-host'
42import { ATPROTO_OPS, bootAtproto, atprotoConfigPath, recoveryVaultPath, genesisPath } from 'favela-host'
43```
44
45### `boot(storage, { swarm, onStage, appUpdate, port, host }) -> { node, swarm, surface, who, atproto }`
46
47Stand a node up and serve it. `atproto` comes back as a **promise**, so a caller that needs the handle
48can wait and a caller that just wants a booted node does not have to.
49
50The caller owns the swarm, and not only for testing: `bare-pack` bundles by physical path, and this
51package resolves `hyperswarm` to a different copy on disk than the Android app does. Importing it here
52put hyperswarm, hyperdht, udx-native and sodium into the worklet bundle twice — measured, 31.8 MB →
5340.1 MB, +26%, for code already in there. `boot` throws without one.
54
55Order, and each step is where it is for a reason:
56
571. `node.ready()` **first** — it builds the overlay, and the transport needs it. The bytes ride the
58 same connections as the log, so a transport without the overlay replicates half the system.
592. `node.announceAll()` — one call, never a hand-rolled list. Joining by hand forgot the namespace and
60 the rooms.
613. `signIn` — **before** the listener exists.
624. `serveSurface`, reusing the port from `PORT_FILE` so a viewer's URL survives a restart.
635. `bootAtproto`, **not awaited**, after the surface is up.
64
65`bootAtproto` is not awaited because it opens a corestore, a swarm and a topic per identity, so on a
66node that holds a DID it can take a long time or never settle. Awaiting it on the handset stopped
67everything below from running, including the update-state write, so the settings page rendered the
68previous boot's snapshot for ever and its banner could not be cleared from the device.
69
70### `inProcessClient(node) -> { call(cmd, args) }`
71
72The same `call(cmd, args) -> Promise` shape `favela-surface`'s own socket `Client` has, so that file
73never learns which it got. Not an emulation: `dispatch` is the layer the socket server itself calls.
74
75It dispatches `ATPROTO_OPS` first, then falls through to `favela-peer`'s `dispatch`. The atproto ops
76cannot live in `dispatch` — favela-peer's boundary test forbids the daemon importing anything
77`atproto-`, and hpp's mint reaches `atproto-identity` — but they can live here, where both halves are
78already in one process, and the surface calls them the way it calls everything else.
79
80### `serveSurface(node, { port, sessions, appUpdate, host }) -> { server, sessions, client, port, url }`
81
82Real routes over real HTTP. Binds `127.0.0.1`: this is your own daemon's data with no authentication
83in front of it, so exposing it must be a decision, never a default.
84
85`appUpdate` is supplied by the embedder. favela-surface's router reads no environment by design, and
86its own test asserts that, so shell state reaches the settings page through `ctx` or not at all. A
87desktop host supplies none and the section correctly does not render.
88
89### `listenPreferring(server, port, host)`
90
91Bind `port` if possible, otherwise any free one. A stable port matters because a viewer holds the URL:
92binding a fresh ephemeral port each restart meant the page someone was looking at pointed at a dead
93server the moment the node came back, which is why "restart to apply" looked like it did nothing. It
94falls back rather than failing — another app can hold the remembered port, and refusing to boot over a
95port number is a worse failure than a changed URL.
96
97### `signIn(node, storage) -> { publicKey, claimed }`
98
99Prove this app's anproto key to the daemon, claiming the instance on first run. The keypair is
100persisted at `<storage>/browser.key`, mode 0600 — a key regenerated each boot would claim the instance
101on first run and be a stranger on the second, permanently locked out, because TOFU closes on the first
102claim and never reopens, not even for the claimant.
103
104It goes over the in-process client, never over HTTP, and that is a security property. TOFU accepts the
105first key to prove itself, and favela-peer's own note says the safety of that rests entirely on the
106surface binding localhost. On Android every app shares loopback, so a claim over the listener could in
107principle be raced by anything else that found the port. Claiming through `dispatch` happens before
108the listener exists and cannot be reached from outside the process at all.
109
110Throws with an actionable message if this key was revoked, or if another browser already claimed the
111node.
112
113### `ATPROTO_OPS`
114
115Six ops, dispatched by `inProcessClient` ahead of the daemon's own verbs.
116
117| op | |
118|---|---|
119| `did-mint` | create an identity, or restore one from a recovery phrase. Never touches the network |
120| `did-submit` | append the genesis to the global PLC log |
121| `did-status` | `{ did, sealed, submitted, publishable }` |
122| `did-carry` | ask a bridge to front this identity, and adopt the handle and PDS that go with it |
123| `did-adopt` | the PLC update, signed by a rotation key |
124| `bsky-project` | project this node's records into its own atproto repo |
125
126Minting is not submitting. Submitting is a permanent, public, irreversible append to a global log, so
127it is a separate verb and nothing that mints can submit by accident. A mint that cannot reach the
128directory still yields a working P2P identity, and it is P2P-only — no handle, no PDS, because
129creating an identity and joining someone's infrastructure are different decisions.
130
131`did-mint` refuses a passphrase under 8 characters **before** minting, not after: the sealed recovery
132vault is designed to be replicated, so it will land on disks we do not control and is safe there only
133because it is useless without something in a person's head. Checking afterwards would leave an
134identity that exists and cannot be protected, and minting cannot be undone. The root mnemonic is
135returned for the caller to show once and is never stored in the clear; sealing failure is reported,
136not thrown, because throwing would destroy a usable identity over a failure to back it up.
137
138`did-carry` is one verb because it is one decision. Fronting without adopting leaves a membrane
139holding a DID that resolves nowhere; adopting without fronting puts a handle and an endpoint on-chain,
140permanently, pointing at a server that has never heard of you. Fronting happens first because it is
141reversible and the PLC update is not.
142
143`did-adopt` needs the passphrase, once, because the rotation key derives from the recovery root and
144that lives sealed. A device can rotate without ever signing, and the signing key in `config.json`
145deliberately cannot do this — keeping the mnemonic unsealed would make "a stolen device yields a
146locked vault" false.
147
148### `bootAtproto(node, { log }) -> hpp | null`
149
150Boots this node's atproto identities, which is what makes a device a **writer** rather than a holder.
151The swarm replicates a DID's repo to every device that boots it; a device that never boots it holds
152nothing. Until this existed the worklet reached exactly two hpp entry points, mint and submit, so a
153phone could create an identity, publish it to the global directory, and author nothing under it.
154
155Returns `null` when there is no identity. A node with no DID is the normal state, not a failure, and a
156read-only node that boots beats a node that does not.
157
158It uses hpp's `loadConfig()`, never `JSON.parse`. The `members` roster has three on-disk shapes —
159`[{key, role}]`, an array of hex strings, and a `"hex,hex"` string — and only the first is understood
160downstream. Hand-parsing made one reader skip the rule, the roster arrived empty, and a room refused
161to project while the config plainly listed three members.
162
163It logs the device public key per DID, because that is the one value another machine needs to admit
164this device to a DID's roster and on a release-signed app it is unreadable from adb. It reports how
165many DIDs actually **booted** against how many were configured: favela-android printed the config's
166count, so it said "signing enabled" while zero nodes had come up.
167
168The booted handle is attached as `node.atproto`, because an op only ever receives `(node, args)` and
169`bsky-project` has to run next to the signing key.
170
171### `lib/project.js`
172
173`deviceClient(hppNode)`, `peerClient(node)`, `pickNode(hpp, credential)`, `projectBsky(node, hpp, …)`.
174
175This exists because the two networks are not symmetric. ap-bridge holds a registered name's feed and
176projects the fediverse from it server-side, so a phone federates with nothing running on it. atproto
177is the opposite: the membrane is keyless and re-serves only what the device signed, so an atproto
178record exists solely because something beside the signing key authored it.
179
180Measured: a phone with a correct PLC record, a resolving handle, an active relay and nine fediverse
181posts had an atproto repo containing one record — `app.bsky.feed.post/self.hello`, hpp's fresh-account
182bootstrap — and no `app.bsky.actor.profile` at all, with `indexedAt: null` on the appview. Nothing was
183failing. Nothing was ever running.
184
185`deviceClient` is a faithful mirror of hpp's `serve-device-uds.js` handlers, not a reinterpretation,
186because the desktop projector talks to those handlers and a drifting second implementation would
187project differently on a phone than on a laptop, silently. It deliberately has no `close`: `BskySink`
188closes its client after every call, which is right for a socket it opened and fatal for the device
189this process is running on.
190
191## Test
192
193```sh
194npm test # brittle-bare test/*.test.js
195```
196
19720 tests, 50 asserts, ~31 s, over `bootAtproto`, `did-adopt`, `did-status` and the projection.
198
199## License
200
201Not yet declared — `package.json` carries no `license` field and there is no LICENSE file.