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