// The host — a favela-peer node with favela-surface rendering it, in one process.
//
// ⛔ ONE PROCESS IS THE SHAPE, and a socket is an ADDITION to it rather than the thing that makes it
// work. `favela-peer/lib/rpc.js` exports `dispatch(node, cmd, a)` next to `createServer(node, sock)`:
// `dispatch` IS the interface and the socket is one adapter over it. On Android there is no socket to
// adapt onto — SELinux refuses an unprivileged app a `sock_file` — so the worklet calls the function.
// On a desktop both are available, so a host can serve pages in-process AND keep the socket for
// `favela-bridge` and the `favela` CLI. Nothing loses a path by this being one process.
//
// ⚠ Everything here is platform-neutral on purpose. What is NOT here: bare-kit IPC, OTA, and the
// shell handoff — those belong to whichever shell is embedding this.
import fs from 'bare-fs'
import http from 'bare-http1'
import { join } from 'bare-path'
import { FavelaNode } from 'favela-peer/lib/node.js'
import { SwarmTransport } from 'favela-peer/lib/transport.js'
import { dispatch } from 'favela-peer/lib/rpc.js'
import { route } from 'favela-surface/lib/router.js'
import { Sessions } from 'favela-surface/lib/session.js'
import { an } from 'anproto'
import { ATPROTO_OPS, bootAtproto } from './atproto.js'
// The app's own browser identity, on disk beside the node's storage.
const BROWSER_KEY = 'browser.key'
// Where the last bound surface port is remembered, so a restart serves the same URL a viewer holds.
export const PORT_FILE = 'surface-port'
/**
* The in-process daemon client — what `route` is handed instead of a socket client.
*
* ⛔ The same `call(cmd, args) -> Promise` shape favela-surface's own `Client` has, so that file
* never learns which it got. This is not an emulation of the socket: `dispatch` is the layer the
* socket server itself calls.
*
* ⛔ THE HOST OWNS THE COMPOSITION, and it has to: favela-peer's boundary test forbids the daemon
* importing anything `atproto-`, and hyper-proto-peer's mint reaches atproto-identity. So the
* atproto ops cannot live in `dispatch` — but they can live here, where both halves are already in
* one process, and the surface calls them the same way it calls everything else.
*/
export function inProcessClient (node) {
return {
call: async (cmd, args = {}) => {
if (ATPROTO_OPS[cmd]) return ATPROTO_OPS[cmd](node, args)
return dispatch(node, cmd, args)
}
}
}
/**
* Bind `port` if we can, otherwise any free one.
*
* ⛔ A STABLE PORT, BECAUSE A VIEWER HOLDS THE URL. Every restart used to bind a fresh ephemeral
* port, so the page someone was looking at pointed at a dead server the moment the node came back —
* which is why "restart to apply" looked like it did nothing and the update banner appeared frozen.
* The node HAD restarted and was serving the new bundle; nobody could see it.
*
* ⚠ Falls back rather than failing. Another app can hold the remembered port, and refusing to boot
* over a port number would be a far worse failure than a changed URL.
*/
export function listenPreferring (server, port, host = '127.0.0.1') {
return new Promise((resolve, reject) => {
const onError = (err) => {
if (!port) return reject(err)
server.removeListener('error', onError)
server.once('error', reject)
server.listen(0, host, resolve)
}
server.once('error', onError)
server.listen(port, host, () => { server.removeListener('error', onError); resolve() })
})
}
/**
* Stand up the real routes over real HTTP, given any node.
*
* ⚠ Binds 127.0.0.1. This is your own daemon's data with NO authentication in front of it — exposing
* it must be a decision, never a default.
*/
export async function serveSurface (node, { port = 0, sessions = new Sessions(), appUpdate = null, host = '127.0.0.1' } = {}) {
const client = inProcessClient(node)
const server = http.createServer((req, res) => {
// ⛔ THE EMBEDDER SUPPLIES IT. favela-surface's router reads no environment by design (its own
// test asserts that), so shell state reaches the settings page through ctx or not at all. A
// desktop host supplies none and the section correctly does not render.
route(req, res, { client, sessions, appUpdate }).catch((err) => {
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
res.end(String((err && err.message) || err))
})
})
await listenPreferring(server, port, host)
const bound = server.address().port
return { server, sessions, client, port: bound, url: 'http://' + host + ':' + bound }
}
/**
* Load or mint this app's anproto keypair.
*
* ⚠ PERSISTED, and it has to be. A key regenerated each boot would claim the instance on first run
* and then be a stranger on the second — permanently locked out, because TOFU closes on the first
* claim and never reopens, not even for the claimant. 0600, and it is the one secret this app keeps
* that is not the node's own.
*/
async function browserKeypair (storage) {
const p = join(storage, BROWSER_KEY)
try {
const kp = fs.readFileSync(p, 'utf8').trim()
if (kp.length === 132) return kp
} catch { /* first run */ }
const kp = await an.gen()
fs.writeFileSync(p, kp + '\n', { mode: 0o600 })
return kp
}
/**
* Prove this app's key to the daemon, claiming the instance on first run.
*
* ⛔ OVER THE IN-PROCESS CLIENT, never over HTTP — and that is a security property, not tidiness.
* TOFU accepts the first key to prove itself, and favela-peer's own note says the safety of that
* "rests ENTIRELY on the surface binding localhost". On Android every app shares loopback, so a
* claim made over the listener could in principle be raced by anything else on the device that found
* the port. Claiming through `dispatch` happens before the listener exists and cannot be reached
* from outside this process at all.
*
* ⚠ `an.sign` signs a HASH, not the challenge — signing the raw string produces a valid signature
* over the wrong thing, which verifies and then fails the hash check.
*/
export async function signIn (node, storage) {
const keypair = await browserKeypair(storage)
const publicKey = an.publicKey(keypair)
const call = (cmd, args = {}) => dispatch(node, cmd, args)
const prove = async () => {
const { challenge } = await call('browser-challenge')
const signature = await an.sign(await an.hash(challenge), keypair)
return { publicKey, signature, challenge }
}
const st = await call('browser-status', { publicKey })
if (st && st.revoked) {
throw new Error('this app\'s browser identity was revoked — a revoked identity stays registered, ' +
'so it needs an invite from the CLI or another signed-in browser')
}
if (st && st.known) {
await call('browser-auth', await prove())
return { publicKey, claimed: false }
}
const { unclaimed } = await call('browser-unclaimed')
if (!unclaimed) {
throw new Error('another browser already claimed this node — TOFU closes on the first claim and ' +
'never reopens, so this app needs a one-time invite')
}
await call('browser-claim', await prove())
return { publicKey, claimed: true }
}
/**
* Sign this app in if it can — and serve either way.
*
* ⛔ THE DESKTOP CASE. On a handset the app IS the browser and `signIn` throwing is correct (the worklet
* relies on it). On a desktop or server the node has usually been claimed by a real browser through the
* surface already, and `boot()` passing that throw up meant the one-app host could not start on either
* server (hyper-social #77 §5, change 2). Serving the surface does not need this app's own session —
* browsers sign themselves in — so a claimed or revoked app key is REPORTED and boot continues.
*
* ⚠ Anything else still throws: a node whose browser registry cannot be read is not one to serve.
*/
export async function signInOrServe (node, storage) {
try {
const who = await signIn(node, storage)
return { ...who, signedIn: true }
} catch (err) {
const reason = (err && err.message) || String(err)
if (/already claimed|revoked/.test(reason)) return { publicKey: null, claimed: false, signedIn: false, reason }
throw err
}
}
/**
* Bring a node up and serve it.
*
* ⛔ THE CALLER OWNS THE SWARM, and that is not only injection-for-testing. bare-pack bundles by
* physical path, and this package resolves `hyperswarm` to a DIFFERENT copy on disk than the Android
* app does — so importing it here put hyperswarm, hyperdht, udx-native and sodium into the worklet
* bundle TWICE. Measured: 31.8 MB → 40.1 MB, +26%, for code that was already in there. A shared
* package taking a heavy transport as an argument costs nothing and cannot duplicate it.
*
* `onStage` reports progress to whatever is embedding this — bare-kit IPC on a handset, a log line
* on a desktop. The host does not know which.
*/
export async function boot (storage, { swarm, onStage = () => {}, appUpdate = null, port = null, host = '127.0.0.1', atproto: atprotoOpts = {} } = {}) {
if (!swarm) throw new Error('boot needs a swarm — the caller constructs it, see the note above')
fs.mkdirSync(storage, { recursive: true })
onStage('storage', { storage })
const node = new FavelaNode(storage, { transport: null, swarm })
// ⛔ BEFORE anything can call an atproto op: the settings page asks did-status at any time, and it must
// read the same config bootAtproto boots (hpp's own, when the embedder named it).
if (atprotoOpts.configFile) node.atprotoConfigFile = atprotoOpts.configFile
// ⛔ ready() FIRST: it builds the overlay, and the transport needs it — the bytes ride the same
// connections as the log, so a transport without the overlay replicates half the system. Same
// order favela-peer's own index.js uses, for the same reason.
await node.ready()
onStage('node ready', { feed: node.key.toString('hex') })
node.transport = new SwarmTransport(swarm, { overlay: node.overlay })
// ⛔ ONE call, and NOT a hand-rolled list. The worklet used to join `node.peer.feed` and
// `node._feeds` itself — precisely the code favela-peer's index.js replaced, and for the reason its
// comment gives: "Three things have needed re-joining by hand here and each was forgotten in turn,
// producing a log that replicates for nobody." Two WERE forgotten: the namespace and the rooms.
const announced = await node.announceAll()
onStage('swarm joined', { announced })
// ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port could be raced by anything
// else that found it; claiming through `dispatch` cannot.
const who = await signInOrServe(node, storage)
onStage(who.signedIn ? (who.claimed ? 'claimed this node' : 'signed in') : 'serving without an app session', who.signedIn ? {} : { reason: who.reason })
// Reuse the port bound last time so a viewer's URL survives a restart.
let wanted = port
if (wanted === null) {
try { wanted = Number(String(fs.readFileSync(join(storage, PORT_FILE), 'utf8')).trim()) || 0 } catch { wanted = 0 }
}
const surface = await serveSurface(node, { port: wanted, appUpdate, host })
try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ }
onStage('serving', { url: surface.url })
// ⛔ AFTER the surface is up, and non-fatal. This is what makes the device a WRITER of its atproto
// identity rather than only a holder of one — the swarm replicates a DID's repo to every device that
// boots it, and a device that never boots it holds nothing. But a node whose atproto half fails must
// still serve its own favela records: favela-android's rule, "a ready the UI cannot use is worse than
// a failure it can read".
// ⛔ NOT AWAITED. It opens a corestore, a swarm and a topic per identity, so on a node that holds a
// DID it can take a long time or never settle. On the handset, awaiting it stopped everything below
// from running — including the write of the update state — so the settings page rendered the
// previous boot's snapshot for ever and its update banner could not be cleared from the device.
//
// ⚠ Returned as a promise, so a caller that genuinely needs the handle can wait for it and a caller
// that just wants a booted node does not have to.
const atproto = bootAtproto(node, { log: (m, d) => onStage(m, d), configFile: atprotoOpts.configFile || null, storage: atprotoOpts.storage || null })
.catch((e) => { onStage('atproto boot failed', { error: (e && e.message) || String(e) }); return null })
return { node, swarm, surface, who, atproto }
}
export { ATPROTO_OPS }
export default { boot, serveSurface, inProcessClient, listenPreferring, signIn, signInOrServe }