OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host / lib / host.js

249 lines · javascript ref:
// 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 }