OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / lib / router.js

1276 lines · javascript ref:
// The router — every route this surface serves, and nothing about how it is booted.
//
// ⛔ Split out of `index.js` so a caller can have the ROUTES without starting a SERVER. Importing
// the entrypoint to reach `route` also bound its port, which is wrong for anyone embedding this:
// a bare-kit worklet wants the routes and supplies its own listener, and on a machine already
// running favela-surface the import simply failed with EADDRINUSE. An entrypoint boots; a library
// exports.
//
// ⛔ `route(req, res, ctx)` has NO default context. This file has no opinion about where the daemon
// is — `ctx.client.call(cmd, args) -> Promise` is the whole contract, satisfied by a socket client
// on a desktop and by a direct `dispatch` binding in a worklet, and nothing here can tell.

import os from '#node:os'
import { join } from '#node:path'
import fs from '#node:fs'
import { sessionIdFrom, setCookie, clearCookie } from './session.js'
import * as pages from './pages.js'
import { embeds } from 'favela-markdown/lib/render.js'
import { recordUri } from 'favela-markdown/lib/uri.js'
import { parse as parseMultipart } from './multipart.js'
import { STATIC } from './static-files.js'
import b4a from 'b4a'

// ⚠ Both spellings: bare-tcp reports IPv4 loopback as `::ffff:127.0.0.1` when the listener is dual
// stack, and a check that knew only one form would refuse the very machine it is running on.
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])

// ⛔ VENDORED in static/, not taken as a dependency. Depending on hyper-identity to serve four
// browser files pulled a whole multi-writer log engine into this renderer's published
// closure — in a process whose entire boundary is "no storage, no keys, no swarm". See
// static/README.md. IMPORTED as text (lib/static-files.js), so a bundle carries them too.

/**
 * ⛔ Per-RESPONSE, keyed by the response object itself. A module-level "current theme" is request
 * state in a shared place, and two interleaved requests would hand one reader the other's setting.
 * A WeakMap keyed by `res` is scoped to exactly one exchange and needs no cleanup.
 */
const THEME = new WeakMap()
// ⛔ The same per-response shape as THEME, for the same reason: applied once at send time so no page
// has to know the banner exists and none can forget it.
const UPDATE = new WeakMap()

const send = (res, html, status = 200) => {
  res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
  res.end(pages.withUpdateBanner(pages.withTheme(html, THEME.get(res) || null), UPDATE.get(res) || null))
}

// The reader's theme, or null for system. A plain cookie — no JS, and the server needs to know it
// before it writes the page or the first paint is the wrong colour.
export function themeFrom (req) {
  const raw = String((req.headers && req.headers.cookie) || '')
  const m = /(?:^|;\s*)favela_theme=(light|dark)(?:;|$)/.exec(raw)
  return m ? m[1] : null
}
const seeOther = (res, to) => { res.writeHead(303, { location: to }); res.end() }

// application/x-www-form-urlencoded, which is what a no-JS form sends.
const body = (req) => new Promise((resolve) => {
  let buf = ''
  req.on('data', (c) => { buf += c.toString() })
  req.on('end', () => {
    const out = {}
    for (const pair of buf.split('&')) {
      if (!pair) continue
      const i = pair.indexOf('=')
      const k = decodeURIComponent((i === -1 ? pair : pair.slice(0, i)).replace(/\+/g, ' '))
      const v = i === -1 ? '' : decodeURIComponent(pair.slice(i + 1).replace(/\+/g, ' '))
      out[k] = v
    }
    resolve(out)
  })
})

/**
 * The settings page, built once and reachable from every route that has to re-render it.
 *
 * ⛔ `whoami` gives this node's own feed key, and the CONFIRMED handle for it comes back through
 * `profiles` like every other byline — the same path, so settings can never show a handle the
 * timeline would not, or the other way round.
 */
async function settingsPage (client, message, token = null, theme = null, appUpdate = null) {
  const me = await client.call('whoami')
  const mine = (await client.call('profiles'))[me.feed] || {}
  const ns = await client.call('namespace')
  // ⚠ Tolerated missing, like `reach`: an older daemon has no `namespaces` command and settings must
  // still render. An empty list makes the section absent rather than claiming this node holds none.
  const known = (await client.call('namespaces').catch(() => ({ namespaces: [] }))).namespaces || []
  // Invites are a separate call: only an admin has any, and the namespace summary is fetched on
  // every settings load by anyone.
  if (ns && ns.carrying) {
    ns.invites = (await client.call('namespace-invites').catch(() => ({ invites: [] }))).invites || []
  }
  // ⚠ Tolerated missing: an older daemon has no `reach` command, and settings must still render.
  // Passing null makes the section absent rather than showing every network as off, which would be
  // a claim this surface cannot support.
  const reach = await client.call('reach').catch(() => null)
  // ⚠ Tolerated missing, and this one is missing in a REAL arrangement rather than only an old one:
  // a surface pointed at a plain favela-peer daemon gets "unknown command", because the atproto verbs
  // live in the composition (favela-host) and not in the daemon. Absent means the section does not
  // render, which is correct — that node genuinely cannot mint one.
  const did = await client.call('did-status').catch(() => null)
  // ⛔ SUPPLIED BY THE EMBEDDER, never read from here. `test/router-import.test.js` asserts this file
  // touches no environment — "an embedder has no env to read" — and it is right: a shell writes
  // `app-update.json`, its own entrypoint knows where, and this file must not learn what a worklet is.
  // Absent on every desktop, where there is no shell and no APK to update, so the section does not
  // render at all rather than being fabricated from what this surface can see.
  let update = null
  try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ }
  return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known, did)
}

/**
 * A same-site path, or `/`.
 *
 * ⛔ `//evil.com` is a protocol-relative URL and a browser follows it off-site, so "starts with a
 * slash" is not enough on its own. Anything else — an absolute URL, a scheme, a backslash a browser
 * may normalise — falls back to the timeline.
 */
function local (to) {
  const s = String(to == null ? '' : to)
  if (!s.startsWith('/') || s.startsWith('//') || s.startsWith('/\\')) return '/'
  // ⚠ The FRAGMENT survives. It is what returns the reader to the post they acted on instead of the
  // top of the page, and stripping it here would quietly undo that.
  return s
}

// "<feed>/<collection>/<name>", the form the post page emits.
function parseTarget (s) {
  const parts = String(s || '').split('/')
  if (parts.length !== 3) return null
  const [feed, collection, name] = parts.map(decodeURIComponent)
  return feed && collection && name ? { feed, collection, name } : null
}

const json = (res, obj, status = 200) => {
  res.writeHead(status, { 'content-type': 'application/json' })
  res.end(JSON.stringify(obj))
}

// ⛔ Bytes. An image posted through a form is binary, and decoding it to UTF-8 anywhere on this
// path corrupts it silently — the symptom is a broken picture, not an error.
const readBytes = (req, limit) => new Promise((resolve, reject) => {
  const chunks = []
  let n = 0
  req.on('data', (c) => {
    n += c.length
    if (n > limit) return reject(new Error('upload too large'))
    chunks.push(c)
  })
  req.on('end', () => resolve(b4a.concat(chunks)))
})

const readJson = (req) => new Promise((resolve) => {
  let buf = ''
  req.on('data', (c) => { buf += c.toString() })
  req.on('end', () => { try { resolve(JSON.parse(buf)) } catch { resolve({}) } })
})

/**
 * Types for every file the given rows reference: oid -> mimeType.
 *
 * ⛔ Resolved BEFORE rendering, because `render` is synchronous and only the daemon knows what a
 * file is. One call per distinct oid, deduped across the whole page.
 *
 * ⚠ Which is one round trip per file on a timeline. Fine at these sizes and not measured; if a page
 * ever holds hundreds of attachments this is the thing to batch.
 */
// ⚠ A REAL limit, and it belongs to this process rather than to the stack: `parseMultipart` needs
// the whole body in memory to find its boundaries, so an upload is buffered here before anything
// else sees it. The content store behind the daemon chunks and has no ceiling of its own, and the
// daemon takes a path — so removing this means streaming the multipart parse to a temp file rather
// than changing any protocol.
const MAX_UPLOAD = 64 * 1024 * 1024

async function fileTypes (rows, client) {
  const oids = new Set()
  for (const r of rows || []) for (const e of embeds(r.body || '')) oids.add(e.oid)
  const out = {}
  for (const oid of oids) {
    const r = await client.call('file-get', { oid }).catch(() => null)
    // ⛔ An unresolved oid is simply absent, and renders as nothing. A file that has not replicated
    // yet is a missing picture, not a broken page.
    if (r && r.mimeType) out[oid] = r.mimeType
  }
  return out
}

export async function route (req, res, ctx) {
  // Read once, before anything renders — the server has to know the theme before it writes the
  // page, or the first paint is the wrong colour and then corrects itself.
  THEME.set(res, themeFrom(req))
  // ⚠ Per request, because the state changes underneath a long-lived page: a node that stages an
  // update while someone is reading should tell them on their next navigation, not on their next
  // restart. It is a small local file, and absent everywhere there is no shell.
  try { if (ctx.appUpdate) UPDATE.set(res, await ctx.appUpdate()) } catch { /* no shell */ }
  const { client, sessions } = ctx
  const url = new URL(req.url, 'http://localhost')
  const path = url.pathname

  // ── the login surface: the only routes reachable without a session ──────────────────────────
  //
  // ⛔ Vendored crypto is served rather than fetched from a CDN. A page that loaded its signer from
  // someone else's server would let that server mint signatures with your key.
  if (req.method === 'GET' && STATIC.has(path)) {
    res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
    return res.end(STATIC.get(path))
  }
  if (req.method === 'GET' && path === '/login') {
    // Asked of the daemon, not remembered here: the surface holds no state, and a stale "unclaimed"
    // would offer a claim that then fails.
    const { unclaimed } = await client.call('browser-unclaimed').catch(() => ({ unclaimed: false }))
    return send(res, pages.login(url.searchParams.get('m'), unclaimed))
  }
  if (req.method === 'POST' && path === '/auth/challenge') return json(res, await client.call('browser-challenge'))
  // ⛔ AUTO-LOGIN. A browser that already holds a registered key must be able to get a session back
  // without an invite, or a surface restart locks out its own owner: sessions are in memory, claim
  // is refused once the instance is claimed, and only the daemon's operator can mint an invite.
  //
  // This existed in the daemon (`browser-auth`) from the start and simply had no route. That gap
  // did exactly what it sounds like.
  if (req.method === 'POST' && path === '/auth/login') {
    const body = await readJson(req)
    try {
      await client.call('browser-auth', body)
      res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
      return res.end(JSON.stringify({ ok: true }))
    } catch (err) {
      return json(res, { error: err.message }, 401)
    }
  }

  // ⛔ THE EMBEDDED-WEBVIEW HANDOFF. The session cookie is HttpOnly, so it cannot be handed to a
  // WebView from outside the browser — `document.cookie` cannot write one. It has to arrive on an
  // HTTP response, which is why this route exists at all and why a worklet minting a session is not
  // by itself enough. favela-web reached the same conclusion for the same reason.
  //
  // ⛔ LOOPBACK ONLY. A handoff turns a token in a URL into a signed-in session, and that is only
  // defensible from the machine the surface is running on.
  //
  // ⛔ The token is ONE-TIME (`redeemHandoff` burns it). favela-web's equivalent accepts a token
  // repeatedly and relies on loopback alone — a real gate on a desktop, and not one on Android,
  // where every app on the device shares loopback. A reusable token in a query string is a
  // replayable credential; a burned one is a race someone has to win once.
  if (req.method === 'GET' && path === '/auth/handoff') {
    const remote = req.socket && req.socket.remoteAddress
    if (!LOOPBACK.has(String(remote || ''))) return send(res, pages.error('Not found'), 404)
    const id = sessions.redeemHandoff(url.searchParams.get('token'))
    if (!id) return seeOther(res, '/login?m=' + encodeURIComponent('That sign-in link was already used.'))
    // ⚠ `next` is sanitised rather than trusted: it must be a path on this surface, so `//evil.com`
    // — which a browser reads as a protocol-relative URL — is refused along with anything absolute.
    const raw = url.searchParams.get('next')
    const next = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
    res.writeHead(303, { location: next, 'set-cookie': setCookie(id) })
    return res.end()
  }

  if (req.method === 'POST' && path === '/auth/claim') {
    const body = await readJson(req)
    try {
      await client.call('browser-claim', body)
      res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
      return res.end(JSON.stringify({ ok: true }))
    } catch (err) {
      return json(res, { error: err.message }, 400)
    }
  }
  if (req.method === 'POST' && path === '/auth/redeem') {
    const body = await readJson(req)
    try {
      await client.call('browser-redeem', body)
      res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': setCookie(sessions.create(body.publicKey)) })
      return res.end(JSON.stringify({ ok: true }))
    } catch (err) {
      return json(res, { error: err.message }, 400)
    }
  }
  if (path === '/logout') {
    const id = sessionIdFrom(req.headers.cookie)
    sessions.destroy(id)
    res.writeHead(303, { location: '/login', 'set-cookie': clearCookie() })
    return res.end()
  }

  // ── the gate ────────────────────────────────────────────────────────────────────────────────
  //
  // ⛔ A session is not a credential. It names the key that proved itself at login, and the daemon
  // is asked on EVERY request whether that key is still allowed — so a revoke lands on the next
  // page load rather than when something expires. That is why there is no expiry to configure.
  const publicKey = sessions.keyFor(sessionIdFrom(req.headers.cookie))
  if (!publicKey) return seeOther(res, '/login')

  const standing = await client.call('browser-status', { publicKey })
  if (!standing.known || standing.revoked) {
    sessions.destroyKey(publicKey) // and no other tab keeps working either
    res.writeHead(303, { location: '/login?m=' + encodeURIComponent('This browser was revoked.'), 'set-cookie': clearCookie() })
    return res.end()
  }

  // ── the identity gate ───────────────────────────────────────────────────────────────────────
  //
  // ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. The gate above proves who is at the keyboard; this
  // one asks whether the NODE is anybody yet. They are different questions with different answers,
  // and conflating them is why an unlinked node used to render a whole timeline and only reveal the
  // problem when a name claim failed with the daemon's error passed through.
  //
  // ⚠ Asked every request, like `browser-status` above and for the same reason: linking happens on
  // another route (or another device, via pairing) and a cached "not linked" would strand someone on
  // this page after they had already succeeded.
  // ⚠ `=== false`, NOT falsy. A daemon that does not report `linked` at all must not be treated as
  // unlinked and lock its owner out of their own node — the same tolerance settings applies to a
  // daemon with no `reach` command. The live daemon always returns a boolean; absence means old, and
  // old is not the same as unlinked.
  const identity = await client.call('whoami')
  if (identity.linked === false) {
    if (req.method === 'GET' && path === '/welcome') {
      return send(res, pages.welcome(url.searchParams.get('m')))
    }
    if (req.method === 'POST' && (path === '/welcome/create' || path === '/welcome/restore')) {
      const form = await body(req)
      const restoring = path === '/welcome/restore'
      if (restoring && !(form.mnemonic || '').trim()) {
        return seeOther(res, '/welcome?m=' + encodeURIComponent('paste your recovery phrase'))
      }
      // ⛔ Same rule as /did/create, and for the same reason: a vault passphrase is typed blind and
      // cannot be reset, so it is confirmed — and confirmed on the server, because a check that
      // lives only in a script does not run when the script does not.
      if (String(form.passphrase || '') !== String(form['passphrase-confirm'] || '')) {
        return seeOther(res, '/welcome?m=' + encodeURIComponent('the two passphrases do not match'))
      }
      try {
        // ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. It is rendered straight into the
        // response and never written, cached or logged here — there is deliberately no route that
        // can show it a second time.
        const r = await client.call('mint-identity', {
          passphrase: (form.passphrase || '').trim() || null,
          mnemonic: restoring ? form.mnemonic.trim() : null
        })
        return send(res, pages.minted(r.mnemonic, r.restored))
      } catch (err) {
        // The daemon's message verbatim — it is the one that knows whether the phrase was invalid,
        // the vault already existed, or the passphrase was wrong.
        return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
      }
    }
    if (req.method === 'POST' && path === '/welcome/pair') {
      const form = await body(req)
      if (!(form.invite || '').trim()) {
        return seeOther(res, '/welcome?m=' + encodeURIComponent('paste an invite'))
      }
      try {
        await client.call('pair-accept', { invite: form.invite.trim() })
        return seeOther(res, '/')
      } catch (err) {
        return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
      }
    }
    // Everything else is unreachable until this node is somebody.
    return seeOther(res, '/welcome')
  }

  if (req.method === 'GET' && path === '/') {
    const rows = await client.call('timeline', { limit: 50 })
    const un = await client.call('unresolved')
    // ⛔ EVERY readable feed's profile, in one call — not just this node's. Asking only for our own
    // is what left other authors rendering as bare hex.
    const profiles = await client.call('profiles')
    // ⛔ `?quote=` is a COMPOSER SEED, not a mode. It becomes editable text in the box and nothing
    // downstream knows a quote happened — which is what "quotes are just links" means in practice.
    const q = url.searchParams.get('quote')
    // ⛔ THE AUTHORITY IS THE IDENTITY ROOT, not the feed key (hyper-social #61 §12 #1). This built
    // `'favela://' + feed + '/' + collection + '/' + name` — a FEED key in the position where a
    // mention carries a ROOT. Both are 64 hex, so nothing downstream could tell which it had, and
    // `isMention` (bare `^favela://`) then classified the whole thing as a mention of a user.
    //
    // A feed key is a LOCATOR; the root is the identity, and it survives device changes and feed
    // migration. `profiles` is already fetched above and carries feed → root for every readable
    // feed, so the resolution costs nothing here.
    //
    // ⚠ If the root is unknown we emit NOTHING rather than falling back to the feed key. A URI goes
    // into a record's bytes and SOCIAL-MODEL §8 forbids rewriting a record you did not author — a
    // wrong address written once is permanent, and an empty composer is recoverable.
    const qt = q ? parseTarget(q) : null
    const qroot = qt && profiles && profiles[qt.feed] ? profiles[qt.feed].root : null
    const quote = qt && qroot ? recordUri({ root: qroot, collection: qt.collection, name: qt.name }) : null
    // ⛔ `[@name](favela://<root>)` — the canonical form. Built here rather than left to the author,
    // because nobody is going to type a 64-character identity root.
    const mr = url.searchParams.get('mention')
    const mh = (url.searchParams.get('handle') || 'them').replace(/[^\w.@-]/g, '')
    const mention = mr && /^[0-9a-f]{64}$/i.test(mr) ? '[@' + mh + '](favela://' + mr + ')' : null
    /**
     * ⛔ An empty timeline has TWO causes and the page cannot tell them apart on its own.
     *
     * Following nobody and following people whose feeds have not arrived are different problems
     * with different fixes, and one sentence for both is the UI version of drawing a converged edge
     * and a resolved one the same way. MEASURED 2026-09-07: stortron follows nobody, its timeline
     * has always been blank, and nothing on the page has ever said why.
     *
     * ⚠ Asked for ONLY when the timeline is empty. It is a scan of our own follow set, and the
     * common path — a timeline with posts in it — must not pay for a message it will not render.
     */
    const emptyState = rows.length ? null : {
      follows: await client.call('following')
        .then((f) => (Array.isArray(f) ? f.length : ((f && f.following) || []).length))
        .catch(() => 0)
    }
    return send(res, pages.timeline(rows, {
      quote, mention, unreadable: un.length, profiles, files: await fileTypes(rows, client),
      message: url.searchParams.get('m'), empty: emptyState
    }))
  }

  if (req.method === 'GET' && path === '/search') {
    const q = url.searchParams.get('q') || ''
    if (!q) return send(res, pages.search('', [], 0, []))
    const r = await client.call('search', { query: q, limit: 25 })
    const profiles = await client.call('profiles')
    // ⛔ The namespace too. `search` only sees records and profiles of feeds this node HOLDS, so a
    // peer you have never followed is invisible to it — which made a shared handle registry
    // unreachable from the one box a person would type a name into.
    const named = await client.call('search-names', { query: q }).catch(() => ({ handles: [] }))
    return send(res, pages.search(q, r.results, r.unreadable, r.people || [], profiles,
      await fileTypes(r.results, client), named.handles || []))
  }

  // One conversation. ⛔ The daemon has had `reply` and `replies` from the beginning and nothing
  // could reach either — a conversation you can only have from a CLI is not shipped.
  if (req.method === 'GET' && path.startsWith('/t/')) {
    const raw = decodeURIComponent(path.slice('/t/'.length))
    const target = parseTarget(raw)
    if (!target) return send(res, pages.error('Not a record'), 404)
    const replies = await client.call('replies', { target })
    // ⚠ The subject may be a post we do NOT hold — we can hold a reply to it and not it. `posts-by`
    // returns what we have of that author, which is empty in that case, and the page says so.
    const subject = (await client.call('posts-by', { feed: target.feed }))
      .find((r) => r.collection === target.collection && r.name === target.name) || null
    const profiles = await client.call('profiles')
    const rows = subject ? [subject, ...replies] : replies
    return send(res, pages.thread(subject, replies, profiles, await fileTypes(rows, client), raw))
  }

  if (req.method === 'GET' && path === '/notifications') {
    const { notifications } = await client.call('notifications')
    return send(res, pages.notifications(notifications, await client.call('profiles')))
  }

  /**
   * Handle suggestions for the composer. JSON, because the only caller is a script.
   *
   * ⛔ CONFIRMED only, and it returns the NAME rather than a root. The picker inserts `@alice` —
   * ordinary text a person could have typed — and the daemon resolves it on the write path exactly
   * as it does for anything typed by hand. So the script is an accelerant and never an authority:
   * with it off, typing the name still works, and with it on it cannot produce a mention the
   * server would not have produced anyway.
   */
  if (req.method === 'GET' && path === '/api/mentions') {
    const q = (url.searchParams.get('q') || '').trim()
    if (!q) return json(res, { people: [] })
    const { handles } = await client.call('search-names', { query: q }).catch(() => ({ handles: [] }))
    const profiles = await client.call('profiles').catch(() => ({}))
    const people = (handles || [])
      .filter((h) => h.verdict === 'confirmed')
      .slice(0, 6)
      .map((h) => ({ handle: h.handle, name: h.name, label: (profiles[h.feed] || {}).name || null }))
    return json(res, { people })
  }

  // JSON for the explorer. ⛔ Same projection as the debug view: two renderings of ONE substrate,
  // which is the claim SOCIAL-MODEL makes about every view. They may disagree about layout, never
  // about facts.
  if (req.method === 'GET' && path === '/api/graph') {
    const g = await client.call('graph')
    /**
     * ⛔ LABELS ARE ATTACHED HERE, NOT IN THE PROJECTION.
     *
     * `graphOf` renders the substrate, and the debug view draws truncated hex on purpose so a
     * diagnosis reads identically on any machine. A name is a display concern of THIS surface, so
     * it travels beside the projection rather than inside it — the two views still cannot disagree
     * about a fact, because the facts are the part that passed through untouched.
     *
     * ⛔ A handle becomes a label only when CONFIRMED. Drawing an unverified one would let anyone
     * claim to be anyone in the single view whose whole job is showing you who is who. A profile
     * name is different and is always shown: it is that feed saying what it calls itself, which is
     * a claim the reader can see is a claim.
     */
    const [profiles, handles] = await Promise.all([
      client.call('profiles').catch(() => ({})),
      client.call('handles').then((r) => (r && r.handles) || []).catch(() => [])
    ])
    const labels = {}
    for (const feed of Object.keys(profiles || {})) {
      const p = profiles[feed] || {}
      // ⚠ The avatar OID, not the bytes. /avatar/<oid> already streams it from the daemon's disk,
      // content-addressed and immutable, so the picture can load it like any other image.
      if (p.name || p.avatar) labels[feed] = { name: p.name || null, avatar: p.avatar || null }
    }
    for (const h of handles) {
      if (!h || h.verdict !== 'confirmed' || !h.feed) continue
      labels[h.feed] = Object.assign({}, labels[h.feed], { handle: h.handle })
    }
    return json(res, Object.assign({}, g, { labels }))
  }

  /**
   * What ONE point is. The answer to "i have no idea what the points are", asked per click rather
   * than shipped for every node — a graph payload carrying every post body would be the timeline
   * again, and most of it is never looked at.
   *
   * ⛔ It reports UNREADABILITY as a fact. A pointer we hold whose bytes never arrived is not a
   * missing post (EDGE-GRAPH §4 ③), and rendering it as an empty one destroys the one distinction
   * this whole view exists to make. `readable:false` with `body:null` — never invented text.
   */
  /**
   * One feed's records, excerpted. The door the explorer's proximity reveal needs.
   *
   * ⛔ ONE SCAN PER FEED, not one per point. Revealing a post as the camera drifts near it would
   * otherwise be a request and a `posts-by` scan for every point that came into range, repeatedly,
   * for a picture that is mostly the same twenty records.
   *
   * ⛔ EXCERPTED. A reveal is a few lines on a small plane; shipping whole bodies to render two
   * hundred characters of them turns this back into the timeline, which is what the graph payload
   * was deliberately kept from being. The full text is one click away through /api/node.
   */
  if (req.method === 'GET' && path === '/api/records') {
    const feed = String(url.searchParams.get('feed') || '')
    if (!/^[0-9a-f]{64}$/i.test(feed)) return json(res, { error: 'not a feed key' }, 404)
    const rows = await client.call('posts-by', { feed }).catch(() => [])
    const records = {}
    for (const r of rows || []) {
      const text = String(r.body || '')
      records[r.collection + '/' + r.name] = {
        excerpt: text.length > 260 ? text.slice(0, 260) + '…' : text,
        createdAt: r.createdAt || null
      }
    }
    return json(res, { feed, records })
  }

  if (req.method === 'GET' && path === '/api/node') {
    const id = String(url.searchParams.get('id') || '')
    if (/^[0-9a-f]{64}$/i.test(id)) {
      const [profiles, handles, holding] = await Promise.all([
        client.call('profiles').catch(() => ({})),
        client.call('handles').then((r) => (r && r.handles) || []).catch(() => []),
        client.call('holding', { feed: id }).catch(() => ({}))
      ])
      const p = (profiles || {})[id] || {}
      const h = handles.find((x) => x && x.feed === id && x.verdict === 'confirmed')
      return json(res, {
        type: 'actor',
        id,
        handle: h ? h.handle : null,
        name: p.name || null,
        bio: p.bio || null,
        avatar: p.avatar || null,
        // ⚠ Why a point can sit there with no edges: we know OF them without holding them.
        holding: holding || {},
        href: '/u/' + id
      })
    }
    const t = parseTarget(id)
    if (!t) return json(res, { error: 'not a node' }, 404)
    /**
     * ⛔ WHO INTERACTED, not just that something did. A line between two points says an interaction
     * happened and cannot say what it was or who made it — so the card is where an interaction
     * becomes readable, and the counts and the people have to reach it.
     *
     * ⚠ Three extra scans, paid ONCE PER CLICK on a record. They are not paid for an actor, which
     * has no target to scan for, and never for the graph payload — putting them there would be a
     * scan per edge on every load of the page.
     */
    const [rows, labels, reactions, reposts, replies] = await Promise.all([
      client.call('posts-by', { feed: t.feed }).catch(() => []),
      client.call('profiles').catch(() => ({})),
      client.call('reactions', { target: t }).then((r) => (r && r.reactions) || []).catch(() => []),
      client.call('reposts', { target: t }).then((r) => (r && r.reposts) || []).catch(() => []),
      client.call('replies', { target: t }).catch(() => [])
    ])
    const row = (rows || []).find((r) => r.collection === t.collection && r.name === t.name) || null
    // ⚠ Named where we can name them. A key is an answer, just a poor one — and inventing a name
    // for a feed with no profile would be worse than showing the key.
    const named = (a) => ({ author: a.author, name: ((labels || {})[a.author] || {}).name || null })
    return json(res, {
      interactions: {
        reactions: reactions.map((r) => Object.assign(named(r), { value: r.value || 'like' })),
        reposts: reposts.map(named),
        replies: Array.isArray(replies) ? replies.length : 0
      },
      type: 'record',
      id,
      author: t.feed,
      authorName: ((labels || {})[t.feed] || {}).name || null,
      collection: t.collection,
      readable: !!row,
      body: row ? (row.body || '') : null,
      createdAt: row ? row.createdAt || null : null,
      href: '/t/' + encodeURIComponent(id)
    })
  }

  if (req.method === 'GET' && path === '/explore') {
    return send(res, pages.explore())
  }

  if (req.method === 'GET' && path === '/graph') {
    return send(res, pages.graph(await client.call('graph')))
  }

  if (req.method === 'GET' && path === '/rooms') {
    const { rooms } = await client.call('rooms')
    return send(res, pages.rooms(rooms))
  }

  if (req.method === 'GET' && path.startsWith('/r/')) {
    const key = path.slice('/r/'.length)
    if (!/^[0-9a-f]{64}$/i.test(key)) return send(res, pages.error('Not a room key'), 404)
    let r
    try { r = await client.call('room', { key }) } catch (err) { return send(res, pages.error(err.message), 404) }
    return send(res, pages.room(key, r, await client.call('profiles'), { message: url.searchParams.get('m') }))
  }

  if (req.method === 'GET' && path === '/mentions') {
    const rows = await client.call('mentions')
    const profiles = await client.call('profiles')
    return send(res, pages.mentions(rows, profiles, await fileTypes(rows, client)))
  }

  // ⛔ NO QUERY, DELIBERATELY. `/search` returns without asking the daemon when the box is empty,
  // which is right there — an empty query is not a request for the whole world. A namespace is the
  // one scope where "show me everyone" IS the question, so this asks with none and gets the full
  // claim list. Without it, joining a namespace and then finding nobody was the reported experience.
  // ⛔ WHERE EVERY DEAD-END WARNING SHOULD LEAD. "the log arrived, the bytes did not" says nothing
  // about whose feed or what fixes it, and until now the only way to find out was a shell.
  if (req.method === 'GET' && path === '/status') {
    const [me, ns, following, missing, profiles] = await Promise.all([
      client.call('whoami').catch(() => null),
      client.call('namespace').catch(() => null),
      client.call('following').catch(() => []),
      client.call('missing').catch(() => []),
      client.call('profiles').catch(() => ({}))
    ])
    let update = null
    try { update = ctx.appUpdate ? await ctx.appUpdate() : null } catch { /* no shell */ }
    return send(res, pages.status({ me, ns, following, missing, profiles, update }))
  }

  if (req.method === 'GET' && path === '/discover') {
    const named = await client.call('search-names', {}).catch(() => ({ handles: [] }))
    const ns = await client.call('namespace').catch(() => null)
    // Profiles are only available for feeds this node HOLDS, so most rows here have none — a
    // stranger's display name is unreachable until you follow them. The handle is what identifies
    // them until then, which is why it leads each row.
    const profiles = await client.call('profiles').catch(() => ({}))
    const following = await client.call('following').catch(() => [])
    return send(res, pages.discover(named.handles || [], following, profiles, ns))
  }

  if (req.method === 'GET' && path === '/following') {
    // ⛔ Profiles too. A following list that showed keys could not say WHO you follow, which is the
    // only question that page answers.
    return send(res, pages.following(await client.call('following'), await client.call('missing'),
      await client.call('profiles')))
  }

  // ⚠ Served through the daemon, not from disk: the surface holds no storage, and an avatar may
  // belong to a feed this node replicated rather than one it authored.
  // ⛔ Served through the daemon and streamed FROM DISK. The daemon returns a PATH, not bytes: both
  // processes are on one machine (the control channel is a unix socket), so pushing a file through
  // it as base64 was an encode/decode round trip for nothing. The bytes reached this machine over
  // the peer's own replication, which is where every byte on this machine came from.
  if (req.method === 'GET' && (path.startsWith('/avatar/') || path.startsWith('/blob/'))) {
    const isAvatar = path.startsWith('/avatar/')
    const oid = path.slice((isAvatar ? '/avatar/' : '/blob/').length)
    const r = await client.call(isAvatar ? 'avatar-get' : 'file-get', { oid }).catch(() => null)
    if (!r || !r.path) { res.writeHead(404); return res.end() }
    res.writeHead(200, {
      'content-type': r.mimeType || 'application/octet-stream',
      'content-length': r.size,
      // ⛔ The browser must not second-guess the type. We sniffed the bytes; letting it sniff too is
      // how a text file becomes markup.
      'x-content-type-options': 'nosniff',
      // Content-addressed, so the bytes for an oid can never change.
      'cache-control': 'public, max-age=31536000, immutable'
    })
    return fs.createReadStream(r.path).pipe(res)
  }

  if (req.method === 'GET' && path.startsWith('/u/')) {
    const author = path.slice('/u/'.length)
    if (!/^[0-9a-f]{64}$/i.test(author)) return send(res, pages.error('Not a feed key'), 404)
    // ⛔ Both: `profile` is that feed's own record, `profiles` carries the CONFIRMED handle the
    // daemon attached. Rendering the page from the record alone is what left the handle off it.
    /**
     * ⛔ MERGED, not cherry-picked. This used to copy `handle` across by hand and nothing else, so
     * every field the profiles map gained had to be remembered here — and twice now it was not.
     * #64 §2 records the first time, with `handle` itself: "rendering the page from the record
     * alone is what left the handle off it". `root` was the second, and it silently removed the
     * only way to @ anybody.
     *
     * `profiles` is a superset of `profile` for any feed that has published one; `profile` supplies
     * the empty shape for a feed that has not, so the fallback survives.
     */
    const profile = { ...(await client.call('profile', { feed: author })),
      ...((await client.call('profiles'))[author] || {}) }
    // ⛔ `posts-by`, not a filtered timeline. The timeline excludes PEEKED feeds, so filtering it
    // could never render the page of somebody you had only looked at — which is the case this page
    // exists to serve.
    const rows = await client.call('posts-by', { feed: author })
    const holding = await client.call('holding', { feed: author })
    return send(res, pages.person(author, profile, rows, await fileTypes(rows, client), holding))
  }

  if (req.method === 'GET' && path === '/settings') {
    return send(res, await settingsPage(client, url.searchParams.get('m'), null, themeFrom(req), ctx.appUpdate))
  }

  // ── the namespace ───────────────────────────────────────────────────────────────────────────
  //
  // ⛔ These are the only door on an installed node. `pgh.bin` is one string and favela-peer spends
  // it on the daemon, so `favela namespace --join` cannot be run on a machine that installed the
  // app. A ceremony reachable only from a checkout is a ceremony most people cannot perform.

  // The theme. ⚠ A cookie rather than a stored preference: it must be readable BEFORE the page is
  // written, and it is a property of this browser rather than of the identity.
  if (req.method === 'POST' && path === '/rooms/create') {
    const { key } = await client.call('room-create')
    return seeOther(res, '/r/' + key)
  }
  if (req.method === 'POST' && path === '/rooms/join') {
    const form = await body(req)
    try { await client.call('room-join', { key: String(form.key || '').trim() }) } catch (err) {
      return seeOther(res, '/rooms?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/r/' + String(form.key || '').trim())
  }
  if (req.method === 'POST' && path.startsWith('/r/')) {
    const [, , key, action] = path.split('/')
    if (!/^[0-9a-f]{64}$/i.test(key || '')) return send(res, pages.error('Not a room key'), 404)
    const form = await body(req)
    // ⛔ ROOM-CONTRACT v2. Every action is carried by THIS node's own writer core; nobody adds anybody
    // else's machine. A joiner joins with an invitation, a member's other device binds itself.
    try {
      if (action === 'invite') {
        const inv = await client.call('room-invite', {
          key, role: form.role === 'moderator' ? 'moderator' : 'peer', requiresConfirmation: form.confirm === 'on'
        })
        // ⛔ RENDERED, not redirected. The token is the invitation's signing secret: a redirect would
        // write it into a location header and the browser's history.
        const r = await client.call('room', { key })
        return send(res, pages.room(key, r, await client.call('profiles'), { invitation: inv }))
      }
      if (action === 'post' && String(form.body || '').trim()) await client.call('room-post', { key, body: form.body })
      else if (action === 'join') await client.call('room-join-invite', { key, token: String(form.token || '').trim() })
      else if (action === 'add-device') await client.call('room-add-device', { key })
      else if (action === 'accept') await client.call('room-accept', { key, writer: form.writer })
      else if (action === 'reject') await client.call('room-reject', { key, writer: form.writer })
    } catch (err) {
      return seeOther(res, '/r/' + key + '?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/r/' + key)
  }

  if (req.method === 'POST' && path === '/theme') {
    const form = await body(req)
    const v = String(form.theme || '')
    const attrs = '; Path=/; Max-Age=31536000; SameSite=Lax'
    res.writeHead(303, {
      location: '/settings',
      // "system" CLEARS it. A third value stored as a value would be a setting that shadows the
      // reader's own; absence is what lets prefers-color-scheme through.
      'set-cookie': v === 'light' || v === 'dark'
        ? 'favela_theme=' + v + attrs
        : 'favela_theme=; Path=/; Max-Age=0; SameSite=Lax'
    })
    return res.end()
  }

  // ⛔ REMOVES A LIST ENTRY, NEVER A LOG. The daemon refuses the current namespace and refuses a
  // founded one, and says why in both cases — the message is passed through verbatim because it is
  // the half that knows.
  if (req.method === 'POST' && path === '/namespace/forget') {
    const form = await body(req)
    try {
      await client.call('namespace-forget', { key: (form.key || '').trim() })
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }
  if (req.method === 'POST' && path === '/namespace/join') {
    const form = await body(req)
    try {
      await client.call('namespace-join', { key: String(form.key || '').trim() })
    } catch (err) {
      // The daemon's message verbatim — it is the one that knows which rule was broken.
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }

  if (req.method === 'POST' && path === '/namespace/request') {
    const form = await body(req)
    const token = String(form.token || '').trim()
    try {
      await client.call('namespace-request', token ? { token } : {})
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings?m=' + encodeURIComponent(
      token ? 'asked, with your invite' : 'asked — an admin has to answer it by hand'))
  }

  // ⚠ Renders instead of redirecting, on purpose. The token is a secret shown once, and a redirect
  // would have to carry it in the query string — into browser history and every log that keeps
  // request lines.
  if (req.method === 'POST' && path === '/namespace/invite') {
    let minted
    try {
      minted = await client.call('namespace-invite')
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return send(res, await settingsPage(client, null, minted.token, themeFrom(req), ctx.appUpdate))
  }

  if (req.method === 'POST' && path === '/namespace/admit') {
    const form = await body(req)
    try {
      await client.call('namespace-admit', { writer: String(form.writer || '').trim() })
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }

  if (req.method === 'POST' && path === '/namespace/withdraw') {
    const form = await body(req)
    try {
      await client.call('namespace-uninvite', { hash: String(form.hash || '').trim() })
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }

  // Claim or release a handle. ⚠ Its own route rather than a field on the settings form: that form
  // is multipart because of the avatar, and a name claim is an ordinary urlencoded post. Folding
  // them together would make claiming a name depend on the file-upload parser.
  if (req.method === 'POST' && path === '/handle') {
    const form = await body(req)
    try {
      if (form.release) await client.call('release-name', { handle: form.release })
      else if (form.name && form.name.trim()) await client.call('claim-name', { name: form.name.trim() })
      else return seeOther(res, '/settings?m=' + encodeURIComponent('type a name to claim'))
    } catch (err) {
      // ⛔ The daemon's message is passed through verbatim — it is the one that knows why. An
      // unlinked device says so, and a malformed name says which rule it broke.
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }

  // ── Flow 2 — a name on a bridge ────────────────────────────────────────────────────────────────
  //
  // ⛔ THE PEER PERFORMS IT. This page collects a key and a name; `bridge-claim` fetches a challenge,
  // signs bytes binding both, and sends them. The credential never leaves the daemon and this file
  // never sees one — identity.md §7.5: "favela-web renders the form; the peer performs the op".
  if (req.method === 'POST' && path === '/bridge/claim') {
    const form = await body(req)
    const bridge = String(form.bridge || '').trim()
    const name = String(form.name || '').trim()
    if (!bridge || !name) {
      return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge key and a name are both needed'))
    }
    try {
      const r = await client.call('bridge-claim', { bridge, name, invite: String(form.invite || '').trim() || null })
      // ⛔ RECORDED ONLY AFTER THE BRIDGE AGREED. Writing the name first would leave this node
      // claiming an address it does not hold — the exact thing the old text box did every time.
      if (r.acct) await client.call('set-reach', { fediverse: r.acct }).catch(() => {})
      // ⚠ 202 means claimed but not yet servable. Saying "done" would promise an actor that is dead,
      // and saying "failed" would lose a name that IS held.
      return seeOther(res, '/settings?m=' + encodeURIComponent(r.pending
        ? 'the name ' + (r.acct || name) + ' is yours, but the bridge could not start serving it yet'
        : 'the bridge carries you as ' + (r.acct || name)))
    } catch (err) {
      // The daemon's message verbatim — it knows whether the name was taken, an invite was needed,
      // or the bridge simply did not answer.
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
  }

  // ⛔ THE STEP NOTHING ELSE PERFORMS. Every other verb on the Bluesky card is about the IDENTITY —
  // minting it, publishing it, having a bridge carry it, consenting to use it. None of them authors a
  // record, and the keyless membrane serves only what this device signed, so all four can succeed
  // while the repo stays empty. That is not a hypothetical: a handset reached "carried by
  // fleeky.7rnx.net", relay active, switch on, with one bootstrap record in its repo and no profile.
  //
  // ⚠ The daemon's own log is rendered rather than summarised — the withheld lines say WHY a post did
  // not go, and that is the question a person has after pressing this.
  if (req.method === 'POST' && path === '/did/project') {
    const form = await body(req)
    // ⛔ SEND IS OPT-IN PER PRESS. The dry-run button submits an empty value, so anything other than an
    // explicit "1" plans and publishes nothing — publication onto a network that does not forget is
    // not a thing to do because a form field was missing.
    // ⚠ NOT named `send` — that is this router's own response helper, and shadowing it here turns
    // every successful run into "send is not a function" at the moment it reports what it published.
    const doSend = String(form.send || '') === '1'
    try {
      const r = await client.call('bsky-project', { send: doSend })
      return send(res, pages.projectResult(r))
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
  }

  if (req.method === 'GET' && path === '/bridge/release') {
    const reach = await client.call('reach').catch(() => null)
    if (!reach || !reach.fediverse) return seeOther(res, '/settings')
    return send(res, pages.bridgeRelease(reach.fediverse, url.searchParams.get('m')))
  }

  if (req.method === 'POST' && path === '/bridge/release') {
    const reach = await client.call('reach').catch(() => null)
    if (!reach || !reach.fediverse) return seeOther(res, '/settings')
    // `name@host` — the bridge knows it by the name alone, and the daemon remembers which bridge
    // that was, so nobody has to paste a key again to perform the one irreversible act here.
    const name = String(reach.fediverse).split('@')[0]
    try {
      await client.call('bridge-release', { acct: reach.fediverse, name })
    } catch (err) {
      return seeOther(res, '/bridge/release?m=' + encodeURIComponent(err.message))
    }
    await client.call('set-reach', { fediverse: null }).catch(() => {})
    return seeOther(res, '/settings?m=' + encodeURIComponent('that name has been given up'))
  }

  // ── Flow 1 — the atproto identity ──────────────────────────────────────────────────────────────
  //
  // ⛔ THREE ROUTES FOR THREE COSTS. Minting reaches nothing and is free; showing the phrase is the
  // one moment a secret exists outside the vault; publishing is permanent and public. Each gets its
  // own screen so none of them can be reached by a mis-tap on the one before.

  if (req.method === 'GET' && path === '/did/create') {
    return send(res, pages.didCreate(url.searchParams.get('m')))
  }

  if (req.method === 'POST' && path === '/did/create') {
    const form = await body(req)
    const pass = String(form.passphrase || '')
    // ⛔ CHECKED HERE TOO, not only in the browser. The confirmation field exists because a vault
    // passphrase is typed blind and has no reset — a mistyped one seals the recovery secret away
    // for good — and a check that lives only in a script is a check that does not run when the
    // script does not.
    if (pass !== String(form['passphrase-confirm'] || '')) {
      return seeOther(res, '/did/create?m=' + encodeURIComponent('the two passphrases do not match'))
    }
    try {
      // ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. Rendered straight into this response
      // and never written, cached or logged here — there is deliberately no route that shows it
      // again. Same contract as /welcome/create.
      const r = await client.call('did-mint', { passphrase: pass })
      return send(res, pages.didMinted(r))
    } catch (err) {
      // The daemon's message verbatim: it is the one that knows whether the passphrase was too
      // short, an identity already existed, or the config could not be written.
      return seeOther(res, '/did/create?m=' + encodeURIComponent(err.message))
    }
  }

  // ⛔ DESIGNATION, and it is its own act. identity.md §5.1 gives the rule: a signer needs exactly
  // one credential, and picking one on the author's behalf would publish under an identity they did
  // not choose. Minting does not elect; this does.
  //
  // ⚠ Sends ONLY the credential. `setReach` merges — "a write naming only `networks` leaves the
  // credential alone, and the reverse" — so this cannot switch a network on as a side effect, which
  // is the one thing it must never do.
  if (req.method === 'POST' && path === '/did/designate') {
    const st = await client.call('did-status').catch(() => null)
    if (!st || !st.did) return seeOther(res, '/settings')
    try {
      await client.call('set-reach', { credential: st.did })
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings?m=' + encodeURIComponent(
      'Bluesky posts will be published under ' + st.did))
  }

  // ⛔ GETTING CARRIED — the step that makes a published DID resolvable at all. A mint is P2P-only,
  // so the genesis names no handle and no server; until a bridge grants both and the peer puts them
  // on-chain, Bluesky has nothing to fetch and the identity resolves for nobody.
  if (req.method === 'GET' && path === '/did/carry') {
    const st = await client.call('did-status').catch(() => null)
    if (!st || !st.did) return seeOther(res, '/settings')
    if (st.handle) return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge already carries this identity'))
    // ⚠ Not gated on `submitted`: that flag comes from a local record which only exists for identities
    // minted after it did. Whether the DID is really in the directory is the directory's question, and
    // the adopt step asks it.
    return send(res, pages.didCarry(st.did, url.searchParams.get('m')))
  }

  if (req.method === 'POST' && path === '/did/carry') {
    const form = await body(req)
    const bridge = String(form.bridge || '').trim()
    const name = String(form.name || '').trim()
    const passphrase = String(form.passphrase || '')
    if (!bridge || !name || !passphrase) {
      return seeOther(res, '/did/carry?m=' + encodeURIComponent('a bridge key, a name and your passphrase are all needed'))
    }
    try {
      // ⛔ ONE CALL. The daemon does both halves in the right order — reversible first — so a refusal
      // from the bridge never leaves a permanent append behind it.
      const r = await client.call('did-carry', { bridge, name, passphrase })
      return seeOther(res, '/settings?m=' + encodeURIComponent(
        r.pending
          ? 'published as ' + r.handle + ', but the bridge is not serving it yet'
          : 'published as ' + r.handle))
    } catch (err) {
      // Verbatim: the daemon knows whether the name was taken, the passphrase was wrong, or the
      // directory refused the operation — and those need different actions.
      return seeOther(res, '/did/carry?m=' + encodeURIComponent(err.message))
    }
  }

  if (req.method === 'GET' && path === '/did/publish') {
    const st = await client.call('did-status').catch(() => null)
    if (!st || !st.did) return seeOther(res, '/settings')
    if (st.submitted) {
      return seeOther(res, '/settings?m=' + encodeURIComponent('that identity is already published'))
    }
    return send(res, pages.didPublish(st.did, url.searchParams.get('m')))
  }

  if (req.method === 'POST' && path === '/did/publish') {
    try {
      const r = await client.call('did-submit', {})
      return seeOther(res, '/settings?m=' + encodeURIComponent(
        r.already ? 'that identity was already published' : 'published to the Bluesky network'))
    } catch (err) {
      // ⚠ Back to the CONFIRMATION, not to settings. Submitting reaches the network and the common
      // failure is transient, so the page someone lands on should be the one with the button.
      return seeOther(res, '/did/publish?m=' + encodeURIComponent(err.message))
    }
  }

  /**
   * Where this identity publishes.
   *
   * ⛔ The daemon decides, not this page. `set-reach` is gated on an ATTESTED device — the same gate
   * as posting — so a browser that is merely signed in cannot switch a network on. The refusal
   * arrives here as an error and is shown; it is not something to route around.
   *
   * ⚠ An unticked box sends NOTHING, so the absent-means-empty reading is the correct one for a
   * checkbox form: no `reach` field at all means every network off.
   */
  if (req.method === 'POST' && path === '/reach') {
    const form = await body(req)
    // ⛔ ONE NETWORK PER SUBMIT, MERGED. This read every checkbox from a single form, so with one
    // section per network a save would have silently switched the OTHER network off — an unticked box
    // sends nothing, and "absent means off" is only safe when the form carries them all.
    // ⛔ DESIGNATION IS ITS OWN SUBMIT. `setReach` merges, so a form naming only `credential` leaves
    // the networks alone and vice versa — which is what lets one section hold both without either
    // clobbering the other.
    if (Object.prototype.hasOwnProperty.call(form, 'credential')) {
      const credential = String(form.credential || '').trim() || null
      try {
        await client.call('set-reach', { credential })
      } catch (err) {
        return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
      }
      return seeOther(res, '/settings?m=' + encodeURIComponent(
        credential ? 'posts go out under ' + credential : 'no identity is designated, so nothing is published to Bluesky'))
    }

    const network = String(form.network || '').trim()
    if (network !== 'bsky' && network !== 'fediverse') return seeOther(res, '/settings')
    const on = !!form.on

    // ⚠ Read first: a merge needs the other network's current answer, and this page must not be the
    // thing that remembers it.
    const reach = await client.call('reach').catch(() => null)
    const current = new Set(reach && Array.isArray(reach.networks) ? reach.networks : [])
    if (on) current.add(network); else current.delete(network)

    const named = network === 'bsky' ? 'Bluesky' : 'the fediverse'
    try {
      await client.call('set-reach', { networks: [...current] })
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings?m=' + encodeURIComponent(
      on ? 'posts will be sent to ' + named : 'posts will no longer be sent to ' + named))
  }

  if (req.method === 'POST' && path === '/settings') {
    // 3 MiB: each picture is capped at 512 KiB by the daemon and the form can now carry TWO, and
    // the slack is so an oversized picture is refused by the daemon with a message about its size
    // rather than by the transport with nothing useful.
    let form
    try {
      form = parseMultipart(await readBytes(req, 3 * 1024 * 1024), req.headers['content-type'])
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    if (!form) return seeOther(res, '/settings?m=' + encodeURIComponent('expected a form upload'))

    try {
      const set = {}
      if (typeof form.fields.name === 'string') set.name = form.fields.name
      if (typeof form.fields.bio === 'string') set.bio = form.fields.bio
      if (Object.keys(set).length) await client.call('set-profile', set)
      // ⛔ TWO PICTURES, one form. The avatar is the round one; the banner is the wide header.
      // They are independent — submitting the form with only one file set must not clear the other,
      // which is why each is only touched when its own part carries bytes.
      for (const [field, verb] of [['avatar', 'avatar-put'], ['banner', 'banner-put']]) {
        const file = form.files[field]
        if (!file || !file.bytes.length) continue
        // ⛔ A PATH, for the same reason. Written where the daemon can read it; it copies what it
        // keeps, so this temp file is ours to remove.
        const tmp = join(os.tmpdir(), 'favela-upload-' + Date.now() + '-' + Math.random().toString(16).slice(2))
        fs.writeFileSync(tmp, file.bytes)
        try { await client.call(verb, { path: tmp }) } finally { try { fs.unlinkSync(tmp) } catch {} }
      }
    } catch (err) {
      return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
    }
    return seeOther(res, '/settings')
  }

  // ⛔ ASKS FIRST. The delete reaches bsky and the fediverse, so the post's link lands here and only
  // this page's form deletes. Only the record's NAME travels on: the daemon looks it up in MY feed.
  if (req.method === 'GET' && path === '/delete') {
    const t = parseTarget(url.searchParams.get('target'))
    if (!t) return seeOther(res, '/')
    return send(res, pages.deletePost(t.name, local(url.searchParams.get('from')), url.searchParams.get('m')))
  }

  if (req.method === 'GET' && path === '/knocks') {
    return send(res, pages.knocks(await client.call('knocks')))
  }

  // Compose. multipart, because it may carry a file.
  //
  // ⛔ The file is stored FIRST and its oid appended to the body as markdown, so what gets published
  // is an ordinary record. Nothing downstream — replication, rendering, projection — has to know
  // that a post had an attachment; `![](favela://blob/<oid>)` is just a link to a file the overlay
  // holds, and every peer resolves it the same way.
  if (req.method === 'POST' && path === '/post') {
    let form
    try {
      form = parseMultipart(await readBytes(req, MAX_UPLOAD), req.headers['content-type'])
    } catch (err) {
      return seeOther(res, '/?m=' + encodeURIComponent(err.message))
    }
    if (!form) return seeOther(res, '/?m=' + encodeURIComponent('expected a form upload'))

    let body = typeof form.fields.body === 'string' ? form.fields.body : ''
    const file = form.files.file
    if (file && file.bytes.length) {
      // ⛔ A PATH over the socket, not bytes — both processes are on this machine and the daemon
      // copies what it keeps, so this temp file is ours to remove.
      const tmp = join(os.tmpdir(), 'favela-post-' + Date.now() + '-' + Math.random().toString(16).slice(2))
      fs.writeFileSync(tmp, file.bytes)
      // ⛔ THE PYRAMID, when the composer built one. The base is the author's own bytes and always
      // level 0; `level-N` parts are the smaller levels the browser rendered at compose time
      // (SOCIAL-MODEL §6.6.1 — encode once at authoring, so bridges SELECT rather than re-encode).
      //
      // ⚠ Absent levels are the ordinary case, not a failure: a browser without the script, an
      // image already smaller than the lowest rung, or a non-image all send exactly one part. That
      // path is today's behaviour and stays byte-identical.
      const tmps = [tmp]
      const levels = [{ path: tmp, w: Number(form.fields['file-w']) || 0, h: Number(form.fields['file-h']) || 0 }]
      for (let n = 0; ; n++) {
        const part = form.files['level-' + n]
        if (!part || !part.bytes.length) break
        const lp = join(os.tmpdir(), 'favela-level-' + Date.now() + '-' + n + '-' + Math.random().toString(16).slice(2))
        fs.writeFileSync(lp, part.bytes)
        tmps.push(lp)
        levels.push({ path: lp, w: Number(form.fields['level-' + n + '-w']) || 0, h: Number(form.fields['level-' + n + '-h']) || 0 })
      }

      let stored
      try {
        stored = levels.length > 1
          ? await client.call('file-put-levels', { levels })
          : await client.call('file-put', { path: tmp })
      } catch (err) {
        return seeOther(res, '/?m=' + encodeURIComponent(err.message))
      } finally {
        for (const t of tmps) { try { fs.unlinkSync(t) } catch {} }
      }
      // The alt text is the filename the browser sent — the only label we have, and it is escaped
      // wherever it is rendered. ⚠ It is a CLAIM by the uploader; the TYPE came from the bytes.
      const alt = String(file.filename || 'file').replace(/[[\]()]/g, '')
      body = (body ? body.trimEnd() + '\n\n' : '') + '![' + alt + '](favela://blob/' + stored.oid + ')'
    }

    if (!body.trim()) return seeOther(res, '/?m=' + encodeURIComponent('a post needs words or a file'))
    await client.call('post', { body })
    return seeOther(res, '/')
  }

  if (req.method === 'POST') {
    const form = await body(req)

    if (path === '/post/delete') {
      const back = local(form.from)
      try {
        await client.call('post-delete', { name: String(form.name || '') })
      } catch (err) {
        const page = back.split('#')[0]
        return seeOther(res, page + (page.includes('?') ? '&' : '?') + 'm=' + encodeURIComponent(err.message))
      }
      return seeOther(res, back)
    }
    if (path === '/follow') {
      if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('follow', { feed: form.feed })
      return seeOther(res, '/following')
    }
    if (path === '/admit') {
      if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('admit', { feed: form.feed })
      return seeOther(res, '/knocks')
    }
    // ⛔ Two routes, because a like has to be reversible from the page that offered it. The daemon
    // has had `unlike` all along and nothing on the surface could reach it, so a like was one-way
    // from a browser — the same "method with no route" shape as `browser-auth`.
    // Hold a feed to READ it. ⚠ Deliberately its own route rather than a silent side effect of
    // visiting /u/<key>: holding replicates their feed to this disk and joins their discovery
    // topic, so it is a decision a person makes, not one a link makes for them.
    if (path === '/peek') {
      if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('peek', { feed: form.feed })
      return seeOther(res, local(form.from) === '/' ? '/u/' + form.feed : local(form.from))
    }
    if (path === '/reply') {
      const target = parseTarget(form.target)
      const body = String(form.body || '').trim()
      if (!target || !body) return seeOther(res, local('/t/' + encodeURIComponent(String(form.target || ''))))
      await client.call('reply', { target, body })
      return seeOther(res, '/t/' + encodeURIComponent(String(form.target)))
    }
    if (path === '/block' || path === '/unblock') {
      if (/^[0-9a-f]{64}$/i.test(form.feed || '')) {
        try { await client.call(path === '/block' ? 'block' : 'unblock', { feed: form.feed }) } catch (err) {
          return seeOther(res, '/u/' + form.feed + '?m=' + encodeURIComponent(err.message))
        }
      }
      return seeOther(res, local(form.from) === '/' ? '/u/' + form.feed : local(form.from))
    }
    if (path === '/repost' || path === '/unrepost') {
      const target = parseTarget(form.target)
      if (target) await client.call(path === '/repost' ? 'repost' : 'unrepost', { target })
      return seeOther(res, local(form.from))
    }
    if (path === '/react' || path === '/unreact') {
      const target = parseTarget(form.target)
      const value = String(form.value || '').trim()
      // `like` keeps its own daemon command — it is the one value both target networks carry.
      const cmd = value === 'like'
        ? (path === '/react' ? 'like' : 'unlike')
        : (path === '/react' ? 'react' : 'unreact')
      if (target && value) await client.call(cmd, value === 'like' ? { target } : { target, value })
      return seeOther(res, local(form.from))
    }
    if (path === '/like' || path === '/unlike') {
      const target = parseTarget(form.target)
      if (target) await client.call(path === '/like' ? 'like' : 'unlike', { target })
      // ⚠ Back where you came from. It used to always redirect to `/`, so liking a post found in
      // search threw away the search. `local()` refuses anything but a same-site path — an
      // attacker-supplied `from` would otherwise make this an open redirect.
      return seeOther(res, local(form.from))
    }
  }

  return send(res, pages.error('Not found'), 404)
}
export default { route }