OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / test / routes.test.js

1766 lines · javascript ref:
// ★ ROUTE-LEVEL tests — the gap every auth hole in this surface has fallen through.
//
// Every one of them was the same shape: a method existed in the daemon and nothing on the surface
// could reach it. `browser-auth` shipped with no route and locked the owner out of their own
// instance; `profile`/`set-profile` had no CLI door; the whole naming system had no form. A unit
// test on a page function cannot see any of that — it never asks what the SERVER does with a
// request, so a route that does not exist looks exactly like a route nobody tested.
//
// So these drive real HTTP against the real listener, with a STUB DAEMON on a unix socket that
// records every call it receives. That second half matters as much as the first: asserting what the
// surface ASKED lets a test pin "GET /settings actually fetches the namespace", which is the other
// direction the same bug travels.
//
// ⛔ No crypto here, and that is faithful rather than a shortcut: the surface delegates all of it to
// the daemon (`browser-auth`, `browser-claim`) and only decides whether to mint a cookie. A stub
// that answers those exercises exactly the logic this file owns.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import net from '#node:net'
import process from '#node:process'
import { join } from '#node:path'
import http from 'bare-http1'

const PORT = 4200 + Math.floor(Math.random() * 300)
const SOCK = join(fs.mkdtempSync(join(os.tmpdir(), 'favela-routes-')), 's')

// ── the stub daemon ───────────────────────────────────────────────────────────────────────────
//
// Newline-delimited JSON, the same protocol `lib/client.js` speaks. `calls` is the record.
const calls = []
let answers = {}

// ⛔ Asked on EVERY authenticated request. A session is not a credential — it names the key that
// proved itself, and the daemon is consulted each time, so a revoke lands on the next page load
// rather than when something expires. A stub that did not answer this made every page read as
// revoked, which is exactly right and is the first thing these tests taught me.
const DEFAULTS = { 'browser-status': { known: true, revoked: false } }

const daemon = net.createServer((socket) => {
  let buf = ''
  socket.on('data', (chunk) => {
    buf += chunk.toString()
    let i
    while ((i = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, i); buf = buf.slice(i + 1)
      let msg
      try { msg = JSON.parse(line) } catch { continue }
      calls.push({ cmd: msg.cmd, args: msg.args })
      const a = answers[msg.cmd] !== undefined ? answers[msg.cmd] : DEFAULTS[msg.cmd]
      const value = typeof a === 'function' ? a(msg.args) : a
      const res = value instanceof Error
        ? { id: msg.id, ok: false, error: value.message }
        : { id: msg.id, ok: true, value: value === undefined ? {} : value }
      socket.write(JSON.stringify(res) + '\n')
    }
  })
  socket.on('error', () => {})
})

// ── a tiny HTTP client ────────────────────────────────────────────────────────────────────────
function request (method, path, { body, headers = {} } = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({ host: '127.0.0.1', port: PORT, method, path, headers }, (res) => {
      let out = ''
      res.on('data', (c) => { out += c.toString() })
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: out }))
    })
    req.on('error', reject)
    if (body) req.write(body)
    req.end()
  })
}

const called = (cmd) => calls.filter((c) => c.cmd === cmd)
const reset = () => { calls.length = 0 }

let server
let cookie = ''

test('boot the surface against a stub daemon', async (t) => {
  await new Promise((r) => daemon.listen(SOCK, r))
  process.env.FAVELA_SOCK = SOCK
  process.env.FAVELA_SURFACE_PORT = String(PORT)
  const mod = await import('../index.js')
  server = mod.server
  // The listener is bound at import; give it a moment to be accepting.
  await new Promise((r) => setTimeout(r, 250))
  t.ok(server, 'the server is exported, which is what makes any of this testable')
})

test('⛔ every page REDIRECTS to login without a session', async (t) => {
  answers = { 'browser-unclaimed': { unclaimed: true } }
  for (const p of ['/', '/search?q=x', '/following', '/settings', '/knocks', '/u/' + 'ab'.repeat(32)]) {
    const r = await request('GET', p)
    t.is(r.status, 303, p + ' is gated')
    t.ok(String(r.headers.location || '').startsWith('/login'), p + ' sends you to login')
  }
})

test('the login page is reachable WITHOUT a session, or nobody could ever sign in', async (t) => {
  reset()
  answers = { 'browser-unclaimed': { unclaimed: true } }
  const r = await request('GET', '/login')
  t.is(r.status, 200)
  t.is(called('browser-unclaimed').length, 1, 'asked the daemon rather than remembering')
})

test('★ POST /auth/login mints a session — the route that once did not exist', async (t) => {
  reset()
  answers = { 'browser-auth': { ok: true } }
  const r = await request('POST', '/auth/login', {
    body: JSON.stringify({ publicKey: 'aa'.repeat(32), signature: 'x', challenge: 'y' }),
    headers: { 'content-type': 'application/json' }
  })
  t.is(r.status, 200)
  t.is(called('browser-auth').length, 1, 'the daemon does the crypto; this surface does not')
  const setC = String(r.headers['set-cookie'] || '')
  t.ok(setC.includes('favela_session='), 'and a cookie comes back')
  cookie = setC.split(';')[0]
})

test('⛔ a REFUSED login is 401 and mints no cookie', async (t) => {
  reset()
  answers = { 'browser-auth': new Error('that key is not registered') }
  const r = await request('POST', '/auth/login', {
    body: JSON.stringify({ publicKey: 'bb'.repeat(32) }),
    headers: { 'content-type': 'application/json' }
  })
  t.is(r.status, 401)
  t.absent(String(r.headers['set-cookie'] || '').includes('favela_session='), 'no session for a failed proof')
})

test('★ a REVOKED browser is thrown out on its next request, not at some expiry', async (t) => {
  reset()
  // The cookie is still perfectly valid; the daemon simply stops vouching for the key.
  answers = { 'browser-status': { known: true, revoked: true }, timeline: [], unresolved: [], profiles: {} }
  const r = await request('GET', '/', { headers: { cookie } })
  t.is(r.status, 303)
  t.ok(decodeURIComponent(String(r.headers.location)).includes('revoked'), 'and is told why')
  t.ok(String(r.headers['set-cookie'] || '').includes('favela_session=;'), 'the cookie is cleared')
  t.is(called('timeline').length, 0, 'and no content was fetched for them')

  // ⚠ That destroys the session, so the rest of this file needs a fresh one.
  answers = { 'browser-auth': { ok: true } }
  const again = await request('POST', '/auth/login', {
    body: JSON.stringify({ publicKey: 'aa'.repeat(32) }),
    headers: { 'content-type': 'application/json' }
  })
  cookie = String(again.headers['set-cookie'] || '').split(';')[0]
})

test('★ the timeline renders, and asks for what it renders', async (t) => {
  reset()
  answers = {
    timeline: [{ author: 'ab'.repeat(32), collection: 'post', name: 'p1', createdAt: '2026-09-07T00:00:00Z', body: 'hello' }],
    unresolved: [],
    profiles: { ['ab'.repeat(32)]: { name: 'Alice', handle: 'alice0', avatar: null } }
  }
  const r = await request('GET', '/', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('@alice0'), 'the handle reached the byline through the real route')
  t.is(called('timeline').length, 1)
  t.is(called('profiles').length, 1, 'EVERY feed\'s profile, not just ours')
})

test('★ settings asks for the namespace and the handle — the door added last', async (t) => {
  reset()
  answers = {
    whoami: { feed: 'cd'.repeat(32) },
    profiles: { ['cd'.repeat(32)]: { handle: 'me0' } },
    profile: { name: 'Me', avatar: null },
    namespace: { key: 'ef'.repeat(32), claims: 1 }
  }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('namespace').length, 1, 'the namespace key is fetched, not invented')
  t.ok(r.body.includes('@me0'), 'and the handle is shown')
  t.ok(r.body.includes('ef'.repeat(32)), 'along with the key nobody could otherwise share')
})

test('★ POST /handle reaches claim-name and release-name', async (t) => {
  reset()
  answers = { 'claim-name': { handle: 'alice0' }, 'release-name': { released: 'alice0' } }

  const claim = await request('POST', '/handle', {
    body: 'name=alice',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(claim.status, 303)
  t.alike(called('claim-name')[0].args, { name: 'alice' }, 'the form reaches the daemon method')

  reset()
  const rel = await request('POST', '/handle', {
    body: 'release=alice0',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(rel.status, 303)
  t.alike(called('release-name')[0].args, { handle: 'alice0' })
})

test('⛔ a daemon refusal is SHOWN, never swallowed', async (t) => {
  reset()
  answers = { 'claim-name': new Error('this device is not linked') }
  const r = await request('POST', '/handle', {
    body: 'name=alice',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.ok(decodeURIComponent(String(r.headers.location)).includes('not linked'),
    'the daemon\'s own words reach the person, because it is the one that knows why')
})

test('★ a file is served by STREAMING it, not by decoding base64', async (t) => {
  reset()
  const p = join(os.tmpdir(), 'route-blob-' + Date.now() + '.png')
  const bytes = Buffer.concat([Buffer.from([0x89]), Buffer.from('PNG\r\n\x1a\n'), Buffer.alloc(40)])
  fs.writeFileSync(p, bytes)
  answers = { 'file-get': { path: p, mimeType: 'image/png', size: bytes.length } }

  const oid = 'a1'.repeat(16)
  const r = await request('GET', '/blob/' + oid, { headers: { cookie } })
  t.is(r.status, 200)
  t.is(r.headers['content-type'], 'image/png')
  t.is(Number(r.headers['content-length']), bytes.length, 'the whole file, streamed from disk')
  // ⛔ The browser must not second-guess a type we established from the bytes.
  t.is(r.headers['x-content-type-options'], 'nosniff')
  t.alike(called('file-get')[0].args, { oid }, 'and the oid is passed through unchanged')
})

test('⛔ a file the node does not hold is 404, not a broken stream', async (t) => {
  reset()
  answers = { 'file-get': { found: false } }
  const r = await request('GET', '/blob/' + 'b2'.repeat(16), { headers: { cookie } })
  t.is(r.status, 404, 'not replicated yet is missing, not corrupt')
})

test('★ posting with an attachment stores the file THEN publishes markdown', async (t) => {
  reset()
  const oid = 'c3'.repeat(16)
  answers = { 'file-put': { oid, mimeType: 'image/png', size: 48 }, post: { ok: true } }

  const b = '--X\r\nContent-Disposition: form-data; name="body"\r\n\r\nlook at this\r\n' +
    '--X\r\nContent-Disposition: form-data; name="file"; filename="cat.png"\r\n' +
    'Content-Type: image/png\r\n\r\n\x89PNG\r\n\x1a\n0000\r\n--X--\r\n'
  const r = await request('POST', '/post', {
    body: b, headers: { cookie, 'content-type': 'multipart/form-data; boundary=X' }
  })
  t.is(r.status, 303)

  // ⛔ A PATH crosses the socket, never bytes — the whole point of the last change.
  const put = called('file-put')[0]
  t.ok(put && typeof put.args.path === 'string', 'the daemon is handed a path')
  t.absent('data' in (put.args || {}), 'and no base64 anywhere')
  t.absent(fs.existsSync(put.args.path), 'the temp file is cleaned up after')

  // ⛔ What gets published is ORDINARY MARKDOWN. Nothing downstream knows a post had an attachment.
  const body = called('post')[0].args.body
  t.ok(body.includes('look at this'), 'the words are kept')
  t.ok(body.includes('![cat.png](favela://blob/' + oid + ')'), 'and the file is a markdown embed')
})

test('⛔ a post with neither words nor a file is refused', async (t) => {
  reset()
  answers = { post: { ok: true } }
  const r = await request('POST', '/post', {
    body: '--X\r\nContent-Disposition: form-data; name="body"\r\n\r\n   \r\n--X--\r\n',
    headers: { cookie, 'content-type': 'multipart/form-data; boundary=X' }
  })
  t.is(r.status, 303)
  t.is(called('post').length, 0, 'nothing was published')
})

test('an unknown route is 404, and the daemon being down is 502 rather than a blank page', async (t) => {
  reset()
  const nf = await request('GET', '/no-such-page', { headers: { cookie } })
  t.is(nf.status, 404)

  // ⛔ "The daemon is down" and "your timeline is empty" are different facts and must look different.
  answers = { timeline: new Error('no daemon at ' + SOCK) }
  const down = await request('GET', '/', { headers: { cookie } })
  t.is(down.status, 502)
  t.ok(down.body.includes('no daemon'), 'and it says so')
})

test('★ the WebView HANDOFF: a one-time token becomes an HttpOnly cookie', async (t) => {
  reset()
  answers = { 'browser-auth': { ok: true } }

  // ⛔ Why this route exists at all: the session cookie is HttpOnly, so an embedded WebView cannot
  // be handed one from outside — `document.cookie` cannot write it. A worklet minting a session is
  // not enough; the cookie has to arrive on an HTTP response.
  const login = await request('POST', '/auth/login', {
    body: JSON.stringify({ publicKey: 'cc'.repeat(32) }),
    headers: { 'content-type': 'application/json' }
  })
  const id = String(login.headers['set-cookie']).split('=')[1].split(';')[0]

  const { DEFAULT_CTX } = await import('../index.js')
  const token = DEFAULT_CTX.sessions.createHandoff(id)
  t.ok(token && token !== id, 'the handoff token is NOT the session id')

  const r = await request('GET', '/auth/handoff?token=' + token + '&next=/settings')
  t.is(r.status, 303)
  t.is(r.headers.location, '/settings', 'and it honours where to go next')
  t.ok(String(r.headers['set-cookie']).includes('HttpOnly'), 'the cookie is HttpOnly, as it must be')

  // ⛔ ONE-TIME. favela-web's equivalent accepts a token repeatedly and leans on loopback — a real
  // gate on a desktop and not one on Android, where every app shares loopback.
  const again = await request('GET', '/auth/handoff?token=' + token)
  t.is(again.status, 303)
  t.ok(decodeURIComponent(String(again.headers.location)).includes('already used'),
    'a second use is refused and says so')
})

test('⛔ handoff refuses an unknown token and a protocol-relative next', async (t) => {
  const bad = await request('GET', '/auth/handoff?token=' + 'de'.repeat(32))
  t.is(bad.status, 303)
  t.ok(String(bad.headers.location).startsWith('/login'), 'an unknown token signs nobody in')

  const { DEFAULT_CTX } = await import('../index.js')
  const login = await request('POST', '/auth/login', {
    body: JSON.stringify({ publicKey: 'cc'.repeat(32) }), headers: { 'content-type': 'application/json' }
  })
  const id = String(login.headers['set-cookie']).split('=')[1].split(';')[0]
  const tok = DEFAULT_CTX.sessions.createHandoff(id)

  // `//evil.com` is a protocol-relative URL a browser will follow off-host.
  const r = await request('GET', '/auth/handoff?token=' + tok + '&next=//evil.com')
  t.is(r.headers.location, '/', 'an off-host next is discarded, not followed')
})

test('★ the SEAM: route() runs with an in-process client and no socket at all', async (t) => {
  // ⛔ This is the worklet shape. favela-peer's `lib/rpc.js` exports `dispatch(node, cmd, a)`
  // alongside `createServer(node, sock)` — `dispatch` is the interface, the socket is one adapter,
  // and in a bare-kit worklet there is no second process for it to adapt to. A pathname unix socket
  // could not be created there in any case: Android SELinux refuses an unprivileged app a
  // `sock_file`, measured with socat on hardware.
  //
  // So: no `net.connect`, no encoding, no socket file. A context is anything with
  // `call(cmd, args) -> Promise`, and this file never learns which it got.
  const seen = []
  const inProcess = {
    call: async (cmd, args) => {
      seen.push(cmd)
      // Stands in for `dispatch(node, cmd, args)` — a plain async function on the same thread.
      if (cmd === 'browser-status') return { known: true, revoked: false }
      if (cmd === 'timeline') return [{ author: 'ef'.repeat(32), collection: 'post', name: 'p9', createdAt: '2026-09-07T00:00:00Z', body: 'in process' }]
      if (cmd === 'unresolved') return []
      if (cmd === 'profiles') return { ['ef'.repeat(32)]: { handle: 'worklet0', name: null, avatar: null } }
      return {}
    }
  }

  const { route, DEFAULT_CTX } = await import('../index.js')
  const { Sessions } = await import('../lib/session.js')
  const sessions = new Sessions()
  const id = sessions.create('ff'.repeat(32))

  const port = PORT + 1
  const srv = http.createServer((req, res) => {
    route(req, res, { client: inProcess, sessions }).catch(() => { res.writeHead(500); res.end() })
  })
  await new Promise((r) => srv.listen(port, '127.0.0.1', r))
  t.teardown(() => new Promise((r) => srv.close(r)))

  const r = await new Promise((resolve, reject) => {
    const rq = http.request({ host: '127.0.0.1', port, method: 'GET', path: '/',
      headers: { cookie: 'favela_session=' + id } }, (res) => {
      let out = ''
      res.on('data', (c) => { out += c.toString() })
      res.on('end', () => resolve({ status: res.statusCode, body: out }))
    })
    rq.on('error', reject)
    rq.end()
  })

  t.is(r.status, 200, 'a page rendered with no daemon socket in existence')
  t.ok(r.body.includes('@worklet0'), 'and the content came through the injected client')
  t.ok(seen.includes('browser-status'), 'the session gate still consulted the daemon')
  t.ok(seen.includes('timeline'))

  // ⛔ And the default context is untouched — the desktop path still holds a socket client.
  t.ok(DEFAULT_CTX.client && typeof DEFAULT_CTX.client.call === 'function', 'the default still exists')
  t.absent(calls.some((c) => c.cmd === 'timeline' && false), 'the stub daemon was never involved')
})

// ── the namespace ceremony ────────────────────────────────────────────────────────────────────
//
// ⛔ Every one of these is a POST route behind a form on the settings page. On an installed node
// there is no other door: `pgh.bin` is one string and favela-peer spends it on the daemon, so
// `favela namespace --join` cannot be run there at all. A form whose action nothing handles is the
// same bug as a daemon method with no route, seen from the other side.

const NS = {
  key: 'cd'.repeat(32),
  claims: 0,
  founded: true,
  admins: ['aa'.repeat(32)],
  writers: [],
  requests: [{ writer: 'ef'.repeat(32), invited: true }],
  carrying: true
}

test('GET /settings fetches the namespace AND its invites when this node carries the log', async (t) => {
  reset()
  answers = { whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: NS,
    'namespace-invites': { invites: [{ hash: '12'.repeat(32), used: false }] } }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('namespace').length, 1, 'asked the daemon rather than remembering')
  t.is(called('namespace-invites').length, 1, 'and only an admin page asks for invites')
  t.ok(r.body.includes(NS.key), 'the whole key, so it can be handed over')
  t.ok(r.body.includes('action="/namespace/join"'))
})

test('⚠ a node that does not carry the log is not asked for invites it cannot have', async (t) => {
  reset()
  answers = { whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {},
    namespace: { ...NS, carrying: false } }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('namespace-invites').length, 0)
  t.ok(r.body.includes('action="/namespace/request"'), 'it is offered the ask instead')
})

test('★ POST /namespace/join reaches the daemon and redirects', async (t) => {
  reset()
  answers = { 'namespace-join': { key: NS.key, joined: true } }
  const r = await request('POST', '/namespace/join', {
    body: 'key=' + NS.key,
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.alike(called('namespace-join')[0].args, { key: NS.key }, 'the key it was given, trimmed and no more')
})

test('⛔ a refused join comes back as the DAEMON\'s message, not a blank redirect', async (t) => {
  reset()
  answers = { 'namespace-join': new Error('a namespace key is 32 bytes of hex — nope') }
  const r = await request('POST', '/namespace/join', {
    body: 'key=nope',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.ok(decodeURIComponent(String(r.headers.location)).includes('32 bytes of hex'),
    'the daemon knows which rule was broken; the surface must not paraphrase it')
})

test('★ POST /namespace/invite RENDERS the token instead of redirecting with it', async (t) => {
  reset()
  const token = 'ab'.repeat(32)
  answers = { 'namespace-invite': { token, hash: '12'.repeat(32) },
    whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: NS,
    'namespace-invites': { invites: [] } }
  const r = await request('POST', '/namespace/invite', {
    body: '', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  // ⛔ 200, not 303. A redirect would have to carry the secret in the query string, into browser
  // history and every log that keeps request lines.
  t.is(r.status, 200, 'rendered, so the token never reaches a URL')
  t.ok(r.body.includes(token), 'and it is on the page exactly once')
  t.absent(String(r.headers.location || '').includes(token), 'nowhere near a location header')
})

test('POST /namespace/request passes a token through only when there is one', async (t) => {
  reset()
  answers = { 'namespace-request': { asked: 'ef'.repeat(32) } }
  await request('POST', '/namespace/request', {
    body: 'token=', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('namespace-request')[0].args, {}, 'an empty box is not a token')

  reset()
  await request('POST', '/namespace/request', {
    body: 'token=' + 'ab'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('namespace-request')[0].args, { token: 'ab'.repeat(32) })
})

test('POST /namespace/admit and /namespace/withdraw reach the daemon', async (t) => {
  reset()
  answers = { 'namespace-admit': { admitted: 'ef'.repeat(32) }, 'namespace-uninvite': { withdrawn: 'x' } }
  await request('POST', '/namespace/admit', {
    body: 'writer=' + 'ef'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('namespace-admit')[0].args, { writer: 'ef'.repeat(32) })

  reset()
  await request('POST', '/namespace/withdraw', {
    body: 'hash=' + '12'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('namespace-uninvite')[0].args, { hash: '12'.repeat(32) })
})

test('⛔ every namespace route is GATED — none of them is a hole in the session check', async (t) => {
  reset()
  for (const p of ['/namespace/join', '/namespace/request', '/namespace/invite',
    '/namespace/admit', '/namespace/withdraw']) {
    const r = await request('POST', p, { body: '', headers: { 'content-type': 'application/x-www-form-urlencoded' } })
    t.is(r.status, 303, p + ' without a session')
    t.ok(String(r.headers.location || '').startsWith('/login'), p + ' sends you to login')
  }
  t.is(calls.filter((c) => c.cmd.startsWith('namespace')).length, 0,
    'and not one of them reached the daemon')
})

test('★ /unlike EXISTS and reaches the daemon — it never had a route', async (t) => {
  reset()
  answers = { like: {}, unlike: {} }
  const target = 'ab'.repeat(32) + '/post/p1'

  await request('POST', '/like', {
    body: 'target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/search?q=x'),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('like').length, 1)

  const r = await request('POST', '/unlike', {
    body: 'target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/search?q=x'),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  // ⛔ The daemon has had `unlike` since the beginning and nothing on the surface could call it, so
  // a like was one-way from a browser. Same shape as `browser-auth` shipping with no route.
  t.is(called('unlike').length, 1, 'the reverse of a like is reachable')
  t.is(String(r.headers.location), '/search?q=x', 'and it returns you to the page you were on')
})

test('⛔ `from` cannot send a browser off-site', async (t) => {
  reset()
  answers = { like: {} }
  for (const evil of ['//evil.example', 'https://evil.example', 'javascript:alert(1)']) {
    const r = await request('POST', '/like', {
      body: 'target=' + encodeURIComponent('ab'.repeat(32) + '/post/p1') + '&from=' + encodeURIComponent(evil),
      headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
    })
    // `//host` is protocol-relative and a browser follows it off-site, so "starts with /" alone
    // would have been an open redirect on a page anyone can be linked to.
    t.is(String(r.headers.location), '/', evil + ' falls back to the timeline')
  }
})

test('★ GET /search asks the NAMESPACE as well as the held feeds', async (t) => {
  reset()
  answers = {
    search: { results: [], unreadable: 0, people: [] },
    profiles: {},
    'search-names': { handles: [{ handle: 'ashatron0', feed: 'ab'.repeat(32), verdict: 'unheld' }] }
  }
  const r = await request('GET', '/search?q=ashatron', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('search-names').length, 1, 'the box that finds people consults the registry of names')
  t.ok(r.body.includes('@ashatron0'), 'so a peer you have never held is findable')
})

test('⚠ a namespace with nothing to say does not break the search', async (t) => {
  reset()
  answers = {
    search: { results: [], unreadable: 0, people: [] },
    profiles: {},
    'search-names': new Error('no namespace')
  }
  // Search over what you hold must survive a registry that is missing or unreachable — the two are
  // independent answers and losing one must not lose the other.
  const r = await request('GET', '/search?q=x', { headers: { cookie } })
  t.is(r.status, 200, 'still renders')
})

test('★ POST /peek holds a feed to read it, and lands you on their page', async (t) => {
  reset()
  answers = { peek: { feed: 'ab'.repeat(32), peeked: true } }
  const r = await request('POST', '/peek', {
    body: 'feed=' + 'ab'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('peek').length, 1, 'reachable — the daemon method had no door')
  t.is(String(r.headers.location), '/u/' + 'ab'.repeat(32))
})

test('⛔ /peek refuses anything that is not a feed key', async (t) => {
  reset()
  answers = { peek: {} }
  await request('POST', '/peek', {
    body: 'feed=../../etc/passwd',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('peek').length, 0, 'nothing reached the daemon')
})

test('GET /u/<key> reads posts-by, not a filtered timeline', async (t) => {
  reset()
  answers = { profile: {}, profiles: {}, 'posts-by': [], holding: { held: false, peeked: false, following: false } }
  const r = await request('GET', '/u/' + 'ab'.repeat(32), { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('posts-by').length, 1)
  t.is(called('timeline').length, 0, 'the timeline excludes peeked feeds — filtering it could not render this page')
  t.ok(r.body.includes('action="/peek"'), 'and an unheld feed offers a look')
})

test('★ /react routes a like to `like` and an emoji to `react`', async (t) => {
  reset()
  answers = { like: {}, react: {} }
  const target = 'ab'.repeat(32) + '/post/p1'
  const body = (v, f) => 'target=' + encodeURIComponent(target) + '&value=' + encodeURIComponent(v) +
    '&from=' + encodeURIComponent(f || '/')
  const h = { cookie, 'content-type': 'application/x-www-form-urlencoded' }

  await request('POST', '/react', { body: body('like'), headers: h })
  // ⛔ `like` keeps its own daemon command. It is the one value both target networks can carry, so
  // a bridge must never have to infer it from a reaction value that happened to be a thumb.
  t.is(called('like').length, 1, 'like goes to `like`')
  t.is(called('react').length, 0)

  reset()
  await request('POST', '/react', { body: body('🔥'), headers: h })
  t.is(called('react').length, 1)
  t.alike(called('react')[0].args, { target: { feed: 'ab'.repeat(32), collection: 'post', name: 'p1' }, value: '🔥' })
})

test('/unreact is the inverse of both', async (t) => {
  reset()
  answers = { unlike: {}, unreact: {} }
  const target = 'ab'.repeat(32) + '/post/p1'
  const h = { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  await request('POST', '/unreact', { body: 'target=' + encodeURIComponent(target) + '&value=like', headers: h })
  t.is(called('unlike').length, 1)
  reset()
  await request('POST', '/unreact', { body: 'target=' + encodeURIComponent(target) + '&value=' + encodeURIComponent('🔥'), headers: h })
  t.is(called('unreact').length, 1)
})

test('⛔ a reaction with no value reaches the daemon at all', async (t) => {
  reset()
  answers = { react: {}, like: {} }
  await request('POST', '/react', {
    body: 'target=' + encodeURIComponent('ab'.repeat(32) + '/post/p1') + '&value=',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  // An empty value is not a reaction. The daemon bounds it too, but a surface that forwards junk
  // makes the daemon's error the user's problem.
  t.is(called('react').length + called('like').length, 0)
})

test('★ /repost and /unrepost reach the daemon', async (t) => {
  reset()
  answers = { repost: {}, unrepost: {} }
  const target = 'ab'.repeat(32) + '/post/p1'
  const h = { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  await request('POST', '/repost', { body: 'target=' + encodeURIComponent(target), headers: h })
  t.is(called('repost').length, 1)
  const r = await request('POST', '/unrepost', {
    body: 'target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/u/' + 'ab'.repeat(32)), headers: h })
  t.is(called('unrepost').length, 1)
  t.is(String(r.headers.location), '/u/' + 'ab'.repeat(32), 'and returns you where you were')
})

// ⛔ THIS TEST USED TO ASSERT THE BUG. It seeded the composer with `favela://<FEED KEY>/…` and
// checked for it. A mention carries an identity ROOT (dialect.js:37) and both are 64 hex, so the
// authority meant two different things depending on which code path built it — hyper-social
// #61 §12 #1. The authority is the ROOT; a feed key is a locator.
test('⛔ ?quote= seeds the composer with a ROOT-authority URI, and creates NO record', async (t) => {
  reset()
  const feed = 'ab'.repeat(32)
  const root = 'cd'.repeat(32)
  answers = { timeline: [], unresolved: [], profiles: { [feed]: { root } } }
  const target = feed + '/post/p1'
  const r = await request('GET', '/?quote=' + encodeURIComponent(target), { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('favela://' + root + '/post/p1'), 'the authority is the identity root')
  t.absent(r.body.includes('favela://' + feed), 'never the feed key')
  // "quotes are just links": nothing is written until the author posts, and what they post is an
  // ordinary post. A quote that created a record on page load would be a different design.
  t.is(calls.filter((c) => c.cmd === 'post').length, 0, 'nothing was written')
})

// ⚠ A URI goes into a record's bytes, and SOCIAL-MODEL §8 forbids rewriting a record you did not
// author — so a wrong address written once is permanent. An empty composer is recoverable.
test('⛔ a quote whose root cannot be resolved seeds NOTHING, never a feed-key address', async (t) => {
  reset()
  const feed = 'ab'.repeat(32)
  answers = { timeline: [], unresolved: [], profiles: {} }
  const r = await request('GET', '/?quote=' + encodeURIComponent(feed + '/post/p1'), { headers: { cookie } })
  t.is(r.status, 200)
  t.absent(r.body.includes('favela://' + feed), 'no fallback to the locator')
})

test('⚠ a malformed ?quote= seeds nothing rather than garbage', async (t) => {
  reset()
  answers = { timeline: [], unresolved: [], profiles: {} }
  const r = await request('GET', '/?quote=' + encodeURIComponent('not-a-target'), { headers: { cookie } })
  t.is(r.status, 200)
  t.absent(r.body.includes('favela://not-a-target'))
})

test('★ GET /t/<target> is a thread — the route replies never had', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    replies: [{ author: A, collection: 'reply', name: 'r1', body: 'a reply' }],
    'posts-by': [{ author: A, collection: 'post', name: 'p1', body: 'the subject' }],
    profiles: {}
  }
  const r = await request('GET', '/t/' + encodeURIComponent(A + '/post/p1'), { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('replies').length, 1, 'the daemon method finally has a door')
  t.ok(r.body.includes('the subject') && r.body.includes('a reply'))
})

test('⚠ a thread whose SUBJECT we do not hold renders as such', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = { replies: [], 'posts-by': [], profiles: {} }
  const r = await request('GET', '/t/' + encodeURIComponent(A + '/post/p1'), { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('cannot be read'), 'not silently an empty conversation')
})

test('POST /reply reaches the daemon and returns to the thread', async (t) => {
  reset()
  answers = { reply: { name: 'r2' } }
  const A = 'ab'.repeat(32)
  const target = A + '/post/p1'
  const r = await request('POST', '/reply', {
    body: 'target=' + encodeURIComponent(target) + '&body=' + encodeURIComponent('hello'),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('reply').length, 1)
  t.ok(String(r.headers.location).startsWith('/t/'), 'back to the conversation, not the timeline')
})

test('⛔ an empty reply writes nothing', async (t) => {
  reset()
  answers = { reply: {} }
  await request('POST', '/reply', {
    body: 'target=' + encodeURIComponent('ab'.repeat(32) + '/post/p1') + '&body=%20%20',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('reply').length, 0, 'whitespace is not a reply')
})

test('★ GET /mentions asks the daemon for posts naming me', async (t) => {
  reset()
  answers = { mentions: [], profiles: {} }
  const r = await request('GET', '/mentions', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('mentions').length, 1)
})

test('★ GET /following asks for profiles, or it can only show keys', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = { following: [A], missing: [], profiles: { [A]: { name: 'Alice', bio: 'hi' } } }
  const r = await request('GET', '/following', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('profiles').length, 1, 'the page that answers "who do I follow" fetches who they are')
  t.ok(r.body.includes('Alice') && r.body.includes('hi'))
})

test('POST /settings sends the bio, and omits what was not submitted', async (t) => {
  reset()
  answers = { 'set-profile': {}, whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null }
  const b = '--X\r\nContent-Disposition: form-data; name="name"\r\n\r\nAlice\r\n' +
            '--X\r\nContent-Disposition: form-data; name="bio"\r\n\r\nabout me\r\n--X--\r\n'
  await request('POST', '/settings', {
    body: b, headers: { cookie, 'content-type': 'multipart/form-data; boundary=X' }
  })
  const call = called('set-profile')[0]
  t.alike(call.args, { name: 'Alice', bio: 'about me' }, 'both fields, and nothing invented')
})

test('★ /block and /unblock reach the daemon', async (t) => {
  reset()
  answers = { block: {}, unblock: {} }
  const A = 'ab'.repeat(32)
  const h = { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  const r = await request('POST', '/block', { body: 'feed=' + A, headers: h })
  t.is(called('block').length, 1)
  t.is(String(r.headers.location), '/u/' + A, 'back to their page, where the button was')
  reset()
  await request('POST', '/unblock', { body: 'feed=' + A, headers: h })
  t.is(called('unblock').length, 1)
})

test('⛔ a refused block surfaces the daemon\'s reason', async (t) => {
  reset()
  answers = { block: new Error('you cannot block yourself') }
  const A = 'ab'.repeat(32)
  const r = await request('POST', '/block', {
    body: 'feed=' + A, headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.ok(decodeURIComponent(String(r.headers.location)).includes('cannot block yourself'),
    'not swallowed into a silent redirect')
})

test('★ GET /notifications is a projection — one call, nothing delivered', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    notifications: { notifications: [{ kind: 'follow', author: A, target: null }] },
    profiles: { [A]: { name: 'Alice' } }
  }
  const r = await request('GET', '/notifications', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('notifications').length, 1)
  t.ok(r.body.includes('followed you'))
  // ⛔ Nothing was marked, consumed or acknowledged. It is derived on read, so there is no state to
  // change by looking at it — which is also why there is no read-state yet.
  t.is(calls.filter((c) => c.cmd.includes('seen') || c.cmd.includes('ack')).length, 0)
})

test('★ a like returns you to the POST, not the top of the page', async (t) => {
  reset()
  answers = { like: {} }
  const A = 'ab'.repeat(32)
  // The form carries `from` with a fragment; a no-JS action is a POST and a redirect, so without it
  // liking the twentieth post throws the reader back to the first.
  const r = await request('POST', '/react', {
    body: 'target=' + encodeURIComponent(A + '/post/p1') + '&value=like' +
      '&from=' + encodeURIComponent('/#p-' + A + '-post-p1'),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(String(r.headers.location), '/#p-' + A + '-post-p1', 'the fragment survives the redirect')
})

test('★ the theme is a cookie, and "system" CLEARS it', async (t) => {
  reset()
  const h = { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  const dark = await request('POST', '/theme', { body: 'theme=dark', headers: h })
  t.ok(String(dark.headers['set-cookie']).includes('favela_theme=dark'))

  const sys = await request('POST', '/theme', { body: 'theme=system', headers: h })
  // ⛔ Absence is what lets prefers-color-scheme through. Storing "system" as a value would be a
  // setting that shadows the reader's own.
  t.ok(String(sys.headers['set-cookie']).includes('Max-Age=0'), 'system removes the cookie')

  const junk = await request('POST', '/theme', { body: 'theme=neon', headers: h })
  t.ok(String(junk.headers['set-cookie']).includes('Max-Age=0'), 'and anything unknown is not stored')
})

test('a forced theme reaches the HTML, so the first paint is right', async (t) => {
  reset()
  answers = { timeline: [], unresolved: [], profiles: {} }
  const r = await request('GET', '/', { headers: { cookie: cookie + '; favela_theme=dark' } })
  // ⚠ Assert on the <html> TAG. The stylesheet contains `:root[data-theme=dark]` selectors, so a
  // substring search for "data-theme" finds the CSS and passes whatever the document says.
  const htmlTag = (b) => (b.match(/<html[^>]*>/) || [''])[0]
  t.ok(htmlTag(r.body).includes('data-theme="dark"'), 'stamped on the document, not applied by script')
  const plain = await request('GET', '/', { headers: { cookie } })
  t.absent(htmlTag(plain.body).includes('data-theme'), 'and absent by default — system wins')
})

test('★ the person page carries EVERY field the profiles map has', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    profile: { name: 'Alice', avatar: null, bio: 'hi' },
    profiles: { [A]: { name: 'Alice', avatar: null, bio: 'hi', handle: 'alice0', root: 'ef'.repeat(32) } },
    'posts-by': [],
    holding: { held: true, peeked: false, following: true, opening: false, blocked: false }
  }
  const r = await request('GET', '/u/' + A, { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('@alice0'), 'the handle reached the page')
  // ⛔ And the ROOT, which is what the mention affordance needs. The route used to copy `handle`
  // across by hand and nothing else, so this control silently did not exist. Asserting the RENDERED
  // control rather than the field is what makes the test see the consequence.
  t.ok(r.body.includes('/?mention=' + 'ef'.repeat(32)), 'and so did the root — you can @ them')
})

test('⚠ a feed with no published profile still renders, and offers no mention', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    profile: { name: null, avatar: null, bio: null },
    profiles: {},
    'posts-by': [],
    holding: { held: true, peeked: false, following: true, opening: false, blocked: false }
  }
  const r = await request('GET', '/u/' + A, { headers: { cookie } })
  t.is(r.status, 200, 'the empty shape still renders')
  // No root, no offer — rather than a link that would produce a mention of nobody.
  t.absent(r.body.includes('/?mention='))
})

test('★ /api/mentions suggests CONFIRMED handles only', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    'search-names': { handles: [
      { handle: 'alice0', name: 'alice', feed: A, verdict: 'confirmed' },
      { handle: 'liar0', name: 'liar', feed: 'cd'.repeat(32), verdict: 'MISMATCH' },
      { handle: 'far0', name: 'far', feed: 'ef'.repeat(32), verdict: 'unheld' }
    ] },
    profiles: { [A]: { name: 'Alice Smith' } }
  }
  const r = await request('GET', '/api/mentions?q=a', { headers: { cookie } })
  const { people } = JSON.parse(r.body)
  // ⛔ A picker that offered an unconfirmed handle would be the rename attack with a helpful UI:
  // click a name, get a link to somebody else's feed.
  t.alike(people.map((p) => p.name), ['alice'], 'the confirmed one, and only it')
  t.is(people[0].label, 'Alice Smith', 'with the self-label, so two alices are tellable apart')
  // ⛔ No ROOT in the response. The picker inserts `@alice` — ordinary text — and the daemon
  // resolves it on the write path, so the script can never mint a mention the server would not.
  t.absent('root' in people[0], 'the script is an accelerant, not an authority')
})

test('an empty query suggests nothing rather than everyone', async (t) => {
  reset()
  const r = await request('GET', '/api/mentions?q=', { headers: { cookie } })
  t.alike(JSON.parse(r.body), { people: [] })
  t.is(called('search-names').length, 0, 'and does not ask')
})

test('⛔ the picker route is GATED like every other', async (t) => {
  reset()
  const r = await request('GET', '/api/mentions?q=a')
  t.is(r.status, 303, 'a handle list is not public')
})

// ── rooms ─────────────────────────────────────────────────────────────────────────────────────

const RK = 'ab'.repeat(32)

test('★ GET /rooms lists them, and offers both ways in', async (t) => {
  reset()
  answers = { rooms: { rooms: [{ key: RK, config: { title: 'Berlin' }, members: 3 }] } }
  const r = await request('GET', '/rooms', { headers: { cookie } })
  t.ok(r.body.includes('Berlin') && r.body.includes('3 member'))
  // ⛔ A room key is a capability, so pasting one IS the way in. There is no directory to browse.
  t.ok(r.body.includes('action="/rooms/join"') && r.body.includes('action="/rooms/create"'))
})

test('★★ a room shows a composer only to somebody who may WRITE', async (t) => {
  const base = { key: RK, config: { title: 'Berlin' }, members: [], requests: [], messages: [] }
  for (const [role, expect] of [['admin', true], ['peer', true], ['read-only', false], [null, false]]) {
    reset()
    answers = { room: { ...base, role }, profiles: {} }
    const r = await request('GET', '/r/' + RK, { headers: { cookie } })
    t.is(r.body.includes('action="/r/' + RK + '/post"'), expect, 'composer for ' + role)
  }
  // ⛔ Rendering a composer to a non-member would let them write something apply silently drops —
  // the "it returned, so it worked" failure this stack refuses everywhere else. ROOM-CONTRACT v2:
  // they are offered the way in, which is an invitation somebody inside hands them.
  reset()
  answers = { room: { ...base, role: null, bound: false }, profiles: {} }
  const r = await request('GET', '/r/' + RK, { headers: { cookie } })
  t.ok(r.body.includes('action="/r/' + RK + '/join"') && r.body.includes('name="token"'), 'a non-member is offered the invitation box')
  t.absent(r.body.includes('/request"'), 'and no v1 ask, which nobody can answer any more')
})

test('⛔ a member\'s OTHER device is offered to bind itself, not a composer', async (t) => {
  reset()
  answers = { room: { key: RK, config: {}, members: [], requests: [], messages: [], role: 'peer', bound: false }, profiles: {} }
  const r = await request('GET', '/r/' + RK, { headers: { cookie } })
  // The identity is a member; this machine's core is not bound, so a post from here would be dropped.
  t.absent(r.body.includes('action="/r/' + RK + '/post"'), 'no composer on an unbound device')
  t.ok(r.body.includes('action="/r/' + RK + '/add-device"'), 'the bind button instead')
})

test('⛔ only a MODERATOR or ADMIN is shown join requests and the invite form', async (t) => {
  const q = [{ writer: 'cd'.repeat(32), root: 'ef'.repeat(32), device: 'x', invite: '01' }]
  for (const [role, expect] of [['admin', true], ['moderator', true], ['peer', false]]) {
    reset()
    answers = { room: { key: RK, config: {}, members: [], requests: q, invites: [], messages: [], role, bound: true }, profiles: {} }
    const r = await request('GET', '/r/' + RK, { headers: { cookie } })
    t.is(r.body.includes('action="/r/' + RK + '/accept"'), expect, 'requests shown to ' + role)
    t.is(r.body.includes('action="/r/' + RK + '/reject"'), expect, 'and can be turned down by ' + role)
    t.is(r.body.includes('action="/r/' + RK + '/invite"'), expect, 'invite form for ' + role)
  }
})

test('★ accepting a request is ONE call on the joiner\'s writer', async (t) => {
  reset()
  answers = { 'room-accept': {} }
  await request('POST', '/r/' + RK + '/accept', {
    body: 'writer=' + 'cd'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  // v2: the joiner already bound its own core when it joined; accepting is the whole decision.
  t.is(called('room-accept').length, 1)
  t.is(called('room-accept')[0].args.writer, 'cd'.repeat(32))
  t.is(called('room-add').length + called('room-admit').length, 0, 'no v1 verbs')
})

test('★ joining with an invitation hands the token to the daemon', async (t) => {
  reset()
  answers = { 'room-join-invite': { key: RK } }
  const r = await request('POST', '/r/' + RK + '/join', {
    body: 'token=' + '12'.repeat(32),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('room-join-invite').length, 1)
  t.is(r.status, 303)
})

test('⛔ a created invitation is SHOWN once, never put in a URL', async (t) => {
  reset()
  answers = {
    'room-invite': { id: '01', token: '34'.repeat(32) },
    room: { key: RK, config: {}, members: [], requests: [], invites: [], messages: [], role: 'admin', bound: true },
    profiles: {}
  }
  const r = await request('POST', '/r/' + RK + '/invite', {
    body: 'role=peer', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  // ⛔ The token is the invitation's signing secret. A redirect would carry it in the location
  // header and browser history; rendering the page in the response carries it nowhere else.
  t.is(r.status, 200, 'rendered, not redirected')
  t.ok(r.body.includes('34'.repeat(32)), 'the token is on the page to copy')
})

test('⛔ a room this node is not in reports the daemon\'s reason, not a blank 404', async (t) => {
  reset()
  answers = { room: new Error('this node is not in room abababab… — join it first') }
  const r = await request('GET', '/r/' + RK, { headers: { cookie } })
  t.is(r.status, 404)
  t.ok(r.body.includes('join it first'), '"not in it" and "no such room" are different facts')
})

test('an empty room message writes nothing', async (t) => {
  reset()
  answers = { 'room-post': {} }
  await request('POST', '/r/' + RK + '/post', {
    body: 'body=%20%20', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('room-post').length, 0)
})

// ── the explorer's two JSON doors ─────────────────────────────────────────────────────────────
//
// "i have no idea what the points are" — the picture drew truncated hex and nothing else, so every
// sphere looked like every other sphere. Two things fix that and they belong on the SERVER, which
// is why they are tested here: a label to put on the picture, and a way to ask one node what it is.

test('★ /api/graph carries LABELS, and the projection does not', async (t) => {
  reset()
  const A = 'ab'.repeat(32); const B = 'cd'.repeat(32)
  answers = {
    graph: { me: A, nodes: [{ id: A, type: 'actor', state: 'resolved' }], edges: [], projection: 'unknown' },
    profiles: { [A]: { name: 'Alice', bio: 'here' }, [B]: { name: 'Bob' } },
    handles: { handles: [{ handle: 'alice0', feed: A, verdict: 'confirmed' }, { handle: 'mallory0', feed: B, verdict: 'MISMATCH' }] }
  }
  const r = await request('GET', '/api/graph', { headers: { cookie } })
  t.is(r.status, 200)
  const g = JSON.parse(r.body)
  // ⛔ The projection is untouched — /graph and /explore are two renderings of ONE substrate and
  // must never disagree about facts. A name is this surface's display concern, attached beside it.
  t.alike(g.nodes, [{ id: A, type: 'actor', state: 'resolved' }], 'nodes pass through unchanged')
  t.is(g.labels[A].name, 'Alice')
  t.is(g.labels[A].handle, 'alice0')
  // ⛔ Only a CONFIRMED handle becomes a label. Drawing an unverified name on the picture would let
  // anyone claim to be anyone in the one view whose whole job is showing you who is who.
  t.absent(g.labels[B] && g.labels[B].handle, 'a MISMATCH handle is not drawn')
  t.is(g.labels[B].name, 'Bob', 'a profile name still is — it is their own feed saying it')
})

test('★★ /api/node tells you what ONE point is — including that it cannot be read', async (t) => {
  const A = 'ab'.repeat(32)
  reset()
  answers = {
    'posts-by': [{ author: A, collection: 'post', name: 'p1', body: 'hello world', createdAt: '2026-09-01T00:00:00Z' }],
    profiles: {}, handles: { handles: [] }, holding: { held: true, following: true }
  }
  const r = await request('GET', '/api/node?id=' + encodeURIComponent(A + '/post/p1'), { headers: { cookie } })
  t.is(r.status, 200)
  const n = JSON.parse(r.body)
  t.is(n.type, 'record')
  t.is(n.body, 'hello world', 'the post, because that is the whole question being asked')
  t.is(n.href, '/t/' + encodeURIComponent(A + '/post/p1'), 'and a way OUT of the picture to the real thing')
  t.is(n.readable, true)

  // ⛔ The state that made this view exist. A pointer we hold whose bytes we cannot fetch is NOT a
  // missing post and must not render as an empty one — EDGE-GRAPH §4 ③. The plane has to say so.
  reset()
  answers = { 'posts-by': [], profiles: {}, handles: { handles: [] }, holding: { held: true } }
  const r2 = await request('GET', '/api/node?id=' + encodeURIComponent(A + '/post/gone'), { headers: { cookie } })
  const n2 = JSON.parse(r2.body)
  t.is(n2.readable, false)
  t.is(n2.body, null, 'no invented text')
})

test('★ /api/node on an ACTOR answers with who, not with what they wrote', async (t) => {
  const A = 'ef'.repeat(32)
  reset()
  answers = {
    profiles: { [A]: { name: 'Alice', bio: 'a person' } },
    handles: { handles: [{ handle: 'alice0', feed: A, verdict: 'confirmed' }] },
    holding: { held: true, following: true, peeked: false }
  }
  const r = await request('GET', '/api/node?id=' + A, { headers: { cookie } })
  const n = JSON.parse(r.body)
  t.is(n.type, 'actor')
  t.is(n.handle, 'alice0')
  t.is(n.name, 'Alice')
  t.is(n.href, '/u/' + A)
  t.is(n.holding.following, true, 'and whether I hold them, which is why some points have no edges')
})

test('⛔ /api/node is GATED and refuses a shape it cannot parse', async (t) => {
  const r = await request('GET', '/api/node?id=' + 'ab'.repeat(32))
  t.is(r.status, 303, 'no session, no answer')
  const r2 = await request('GET', '/api/node?id=not-a-node', { headers: { cookie } })
  t.is(r2.status, 404)
})


test('★ a label carries the AVATAR, so a person can be drawn as themselves', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  answers = {
    graph: { me: A, nodes: [], edges: [], projection: 'unknown' },
    profiles: { [A]: { name: 'Alice', avatar: 'cd'.repeat(32) } },
    handles: { handles: [] }
  }
  const g = JSON.parse((await request('GET', '/api/graph', { headers: { cookie } })).body)
  t.is(g.labels[A].avatar, 'cd'.repeat(32), 'the oid, which /avatar/<oid> already serves')
})

// ⛔ The door proximity-reveal needs. Fetching one record at a time as the camera drifts would be a
// request per point and a `posts-by` scan per request; this is ONE scan per feed, asked for once.
test('★★ /api/records returns one feed\'s records, excerpted', async (t) => {
  reset()
  const A = 'ab'.repeat(32)
  const long = 'x'.repeat(900)
  answers = {
    'posts-by': [
      { author: A, collection: 'post', name: 'p1', body: 'short one', createdAt: '2026-09-01T00:00:00Z' },
      { author: A, collection: 'post', name: 'p2', body: long, createdAt: '2026-09-02T00:00:00Z' }
    ]
  }
  const r = await request('GET', '/api/records?feed=' + A, { headers: { cookie } })
  t.is(r.status, 200)
  const out = JSON.parse(r.body).records
  t.is(out['post/p1'].excerpt, 'short one')
  // ⛔ EXCERPTED. A reveal shows a few lines on a small plane; shipping 900 characters to render 200
  // of them is the timeline again, which is the thing this endpoint exists not to be.
  t.ok(out['post/p2'].excerpt.length < 400, 'a long post is cut, not shipped whole')
  t.is(called('posts-by').length, 1, 'one scan for the whole feed')
  t.is(out['post/p1'].createdAt, '2026-09-01T00:00:00Z')
})

test('⛔ /api/records is gated and refuses a non-key', async (t) => {
  t.is((await request('GET', '/api/records?feed=' + 'ab'.repeat(32))).status, 303)
  t.is((await request('GET', '/api/records?feed=nope', { headers: { cookie } })).status, 404)
})

test('★★ /api/node on a record says WHO interacted with it', async (t) => {
  reset()
  const A = 'ab'.repeat(32); const B = 'cd'.repeat(32); const C = 'ef'.repeat(32)
  answers = {
    'posts-by': [{ author: A, collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-01T00:00:00Z' }],
    profiles: { [B]: { name: 'Bob' } },
    reactions: { reactions: [{ author: B, value: 'like' }, { author: C, value: '🔥' }] },
    reposts: { reposts: [{ author: C }] },
    replies: [{ author: B, name: 'r1', body: 'yes' }]
  }
  const n = JSON.parse((await request('GET', '/api/node?id=' + encodeURIComponent(A + '/post/p1'), { headers: { cookie } })).body)
  // ⛔ A line between two points says something happened; it cannot say what. The card is where an
  // interaction becomes readable, so the counts and the people have to reach it.
  t.is(n.interactions.reactions.length, 2)
  t.is(n.interactions.reactions[0].value, 'like')
  t.is(n.interactions.reposts.length, 1)
  t.is(n.interactions.replies, 1)
  // ⚠ 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.
  t.is(n.interactions.reactions.find((r) => r.author === B).name, 'Bob')
  t.is(n.interactions.reactions.find((r) => r.author === C).name, null)
})

test('⚠ an ACTOR node asks for none of that', async (t) => {
  reset()
  const A = 'ef'.repeat(32)
  answers = { profiles: {}, handles: { handles: [] }, holding: {} }
  await request('GET', '/api/node?id=' + A, { headers: { cookie } })
  // Three extra scans per click is the cost of the record answer; an actor has no target to scan
  // for, and asking anyway would spend it for nothing.
  t.is(called('reactions').length, 0)
  t.is(called('reposts').length, 0)
  t.is(called('replies').length, 0)
})

test('★★ an empty timeline asks WHO you follow, and a full one does not', async (t) => {
  reset()
  answers = { timeline: [], unresolved: [], profiles: {}, following: ['ab'.repeat(32)] }
  const r = await request('GET', '/', { headers: { cookie } })
  t.is(r.status, 200)
  t.is(called('following').length, 1, 'asked, because there was nothing to show')
  t.ok(r.body.includes('Nothing has arrived yet'), 'and said which kind of empty it is')

  // ⚠ The common path must not pay for a message it will not render.
  reset()
  answers = {
    timeline: [{ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-01T00:00:00Z' }],
    unresolved: [], profiles: {}, following: []
  }
  await request('GET', '/', { headers: { cookie } })
  t.is(called('following').length, 0, 'not asked when there are posts')
})

// ⛔ ONE NETWORK PER SUBMIT, MERGED WITH THE OTHER. The page now has a section per network, so a
// single form covering both would mean saving one silently switched the other off — an unticked box
// sends nothing, and "absent means off" is only safe when the form carries every network.
test('★★ a toggle changes ONE network and leaves the other alone', async (t) => {
  reset()
  answers = { reach: { networks: ['fediverse'], credential: null }, 'set-reach': {} }
  const r = await request('POST', '/reach', {
    body: 'network=bsky&on=on',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.alike(called('set-reach')[0].args.networks.sort(), ['bsky', 'fediverse'],
    'the fediverse stayed on — it was never part of this form')
})

test('⛔ turning one off does not turn the other off with it', async (t) => {
  reset()
  answers = { reach: { networks: ['bsky', 'fediverse'], credential: null }, 'set-reach': {} }
  await request('POST', '/reach', {
    body: 'network=bsky',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('set-reach')[0].args.networks, ['fediverse'], 'only bsky was removed')
})

test('⛔ a submit naming no known network changes nothing', async (t) => {
  reset()
  answers = { reach: { networks: ['bsky'] }, 'set-reach': {} }
  await request('POST', '/reach', {
    body: 'network=nonsense&on=on',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(called('set-reach').length, 0, 'nothing was written')
})

test('⛔ a daemon that REFUSES the change is reported, not routed around', async (t) => {
  // set-reach is gated on an attested device. A browser that is merely signed in gets a refusal,
  // and the person has to be told why rather than shown a page that looks like it worked.
  reset()
  answers = { 'set-reach': new Error('this device is not admitted to its own feed') }
  const r = await request('POST', '/reach', {
    body: 'network=bsky&on=on', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.ok(/not%20admitted|not admitted/.test(String(r.headers.location)), 'the reason travels: ' + r.headers.location)
})

// ⛔ ONE SECTION PER NETWORK, NAMED. The page had "Publishing", "Bluesky identity" and "Bridge" —
// where "Bridge" was the FEDIVERSE one and Bluesky's bridge was a link inside another card. Two
// sections asked for "a bridge key" and only one named its network, so the obvious move was to paste
// the Bluesky key into the fediverse form. Somebody did, got an error, retried, got another.
test('⚠ each network gets its own named section, in dependency order', async (t) => {
  reset()
  answers = {
    whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null,
    reach: { networks: [], credential: null, fediverse: null },
    'did-status': { did: 'did:plc:abc', sealed: true, submitted: false, handle: null }
  }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.ok(/<h2>Bluesky<\/h2>/.test(r.body), 'Bluesky is its own section')
  t.ok(/<h2>Mastodon and the fediverse<\/h2>/.test(r.body), 'and so is the fediverse')
  t.absent(/<h2>Bridge<\/h2>/.test(r.body), 'no section called just "Bridge" — that was the ambiguity')
  t.ok(r.body.includes('Fediverse bridge key'), 'the bridge field names WHICH network it belongs to')
  t.ok(r.body.includes('cannot be recalled'), 'and the cost is named beside the switch')
})

// ⛔ THE ORDER WAS THE INVISIBLE PART. A switch does nothing without an identity; an identity reaches
// nobody without a bridge. Each step now says what the one below it still needs.
test('⚠ a switch with nothing behind it says so rather than looking enabled', async (t) => {
  reset()
  answers = {
    whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null,
    reach: { networks: ['bsky', 'fediverse'], credential: null, fediverse: null },
    'did-status': { did: 'did:plc:abc', sealed: true, submitted: false, handle: null }
  }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.ok(/No bridge carries this identity yet/.test(r.body), 'Bluesky says why nothing is sent')
  t.ok(/No bridge carries you yet/.test(r.body), 'and so does the fediverse')
})

// ── the identity gate ────────────────────────────────────────────────────────────────────────────
//
// ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. These are separate questions and the surface used to ask
// only the first: an unlinked node rendered a full timeline and revealed the problem only when a name
// claim failed. Written after the code rather than before it, which is the wrong order and is why the
// stub's missing `linked` field broke 175 tests before this existed.
test('⛔ an UNLINKED node can reach nothing but /welcome', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  const r = await request('GET', '/', { headers: { cookie } })
  t.is(r.status, 303)
  t.is(r.headers.location, '/welcome', 'and it is sent to onboarding, not to an empty timeline')
})

test('★ /welcome offers all THREE doors — create, restore, and pair', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  const r = await request('GET', '/welcome', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('/welcome/create'), 'create')
  // ⛔ Restore is the only door that survives losing every device; pair needs one of them awake.
  t.ok(r.body.includes('/welcome/restore'), 'restore')
  t.ok(r.body.includes('/welcome/pair'), 'pair')
})

test('★ POST /welcome/create mints and shows the words ONCE', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  answers['mint-identity'] = { mnemonic: 'alpha bravo charlie delta', device: 'ff', restored: false }
  calls.length = 0
  const r = await request('POST', '/welcome/create', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'passphrase=hunter2&passphrase-confirm=hunter2'
  })
  t.is(r.status, 200)
  const mint = calls.find((c) => c.cmd === 'mint-identity')
  t.ok(mint, 'the daemon was asked to mint')
  t.is(mint.args.passphrase, 'hunter2', 'and the passphrase was passed through')
  t.is(mint.args.mnemonic, null, 'creating, not restoring')
  t.ok(r.body.includes('alpha') && r.body.includes('bravo'), 'the words are on the page')
})

// ⛔ A VAULT PASSPHRASE IS TYPED BLIND AND CANNOT BE RESET, so it is confirmed — and confirmed HERE,
// not only in the browser, because a check that lives in a script does not run when the script does
// not. A mistyped one seals the recovery secret away for good.
test('⛔ POST /welcome/create refuses when the two passphrases differ, and mints nothing', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  answers['mint-identity'] = { mnemonic: 'alpha bravo charlie delta', device: 'ff', restored: false }
  calls.length = 0
  const r = await request('POST', '/welcome/create', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'passphrase=hunter2&passphrase-confirm=hunter3'
  })
  t.is(r.status, 303, 'sent back to the form')
  // ⚠ Decoded: the message rides in the query string, so a raw regex sees %20 and not spaces.
  t.ok(decodeURIComponent(r.headers.location || '').includes('do not match'), 'and told why: ' + r.headers.location)
  t.absent(calls.find((c) => c.cmd === 'mint-identity'), 'nothing was minted')
})

// ⛔ THE ABSENCE IS THE FEATURE. If any GET could re-render the phrase, "a stolen device yields a
// locked vault" would stop being true (hyper-social #48). This asserts there is no such route.
test('⛔ there is NO route that shows the recovery phrase a second time', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  for (const p of ['/welcome/create', '/welcome/minted', '/welcome/mnemonic']) {
    const r = await request('GET', p, { headers: { cookie } })
    t.is(r.status, 303, p + ' does not render the phrase on GET')
  }
})

test('★ POST /welcome/pair takes an invite and never asks for a phrase', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: false }
  answers['pair-accept'] = { ok: true }
  calls.length = 0
  const r = await request('POST', '/welcome/pair', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'invite=abc123'
  })
  t.is(r.status, 303)
  const pair = calls.find((c) => c.cmd === 'pair-accept')
  t.is(pair.args.invite, 'abc123')
  t.absent(calls.find((c) => c.cmd === 'mint-identity'), 'no mint, so no phrase is handled at all')
})

test('a LINKED node passes straight through, and an old daemon is not locked out', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.timeline = []
  answers.unresolved = []
  answers.profiles = {}
  const r = await request('GET', '/', { headers: { cookie } })
  t.not(r.headers.location, '/welcome', 'linked reaches the timeline')

  // ⚠ A daemon that does not report `linked` AT ALL must not read as unlinked — absence means old.
  answers.whoami = { feed: 'ab', device: 'cd' }
  const old = await request('GET', '/', { headers: { cookie } })
  t.not(old.headers.location, '/welcome', 'an older daemon still reaches its own node')
})


// ── the designated credential ────────────────────────────────────────────────────────────────────
//
// ⛔ `setReach` has accepted a credential and `reachSettings` has rendered one since they were
// written, with NOTHING in between — no route, no CLI verb. So the page told people to designate an
// identity "on the machine running the daemon" and there was no such mechanism anywhere. These pin
// the wire that was missing.
test('★ POST /reach sends the credential — the half that was never wired', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['set-reach'] = { networks: ['bsky'], credential: 'did:plc:abc' }
  calls.length = 0
  await request('POST', '/reach', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'credential=did%3Aplc%3Aabc'
  })
  const set = calls.find((c) => c.cmd === 'set-reach')
  t.is(set.args.credential, 'did:plc:abc', 'the DID reaches the daemon')
  // ⛔ AND THE NETWORKS ARE NOT TOUCHED. setReach merges, so designating an identity must not also
  // be a statement about which networks are on — they are separate decisions and separate submits.
  t.absent('networks' in set.args, 'choosing an identity does not switch a network on or off')
})

// ⚠ Clearing must be possible from the only surface that can say it — withholding is the
// fail-closed state, so "stop publishing under this identity" has to be expressible.
test('an empty credential box CLEARS the designation rather than being ignored', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['set-reach'] = { networks: [], credential: null }
  calls.length = 0
  await request('POST', '/reach', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'credential='
  })
  t.is(calls.find((c) => c.cmd === 'set-reach').args.credential, null)
})

// ── Flow 1 — the atproto identity ────────────────────────────────────────────────────────────────
//
// ⛔ WHAT WAS HERE WAS A TEXT BOX. `reachSettings` rendered "Bluesky identity (DID)" and you pasted a
// string into it; minting existed in the composition and publishing existed in hpp, and nothing on
// any surface could reach either. hyper-social #69 §10.7 filed it as "wired a field nothing reads".
//
// ⛔ THE THREE COSTS ARE THREE ROUTES, and these pin that they stay three: minting reaches nothing,
// the phrase is shown exactly once, and publishing is permanent and public.

test('★ settings renders the identity card from did-status', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.profile = { name: 'x' }
  answers.profiles = {}
  answers.namespace = null
  answers['did-status'] = { did: null, sealed: false, submitted: false, publishable: false }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('/did/create'), 'a route to create one exists on the page')
})

// ⚠ A surface pointed at a plain favela-peer gets "unknown command" for did-status, because the
// atproto verbs live in the composition and not in the daemon. That node genuinely cannot mint one,
// so the section must be ABSENT rather than claiming there is no identity.
test('⚠ a daemon that cannot answer did-status renders no identity section at all', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.profile = { name: 'x' }
  answers.profiles = {}
  answers.namespace = null
  answers['did-status'] = new Error('unknown command: did-status')
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200, 'settings still renders')
  t.absent(r.body.includes('/did/create'), 'and offers nothing it cannot do')
})

test('★ POST /did/create mints and shows the words ONCE', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo charlie delta', sealed: true }
  calls.length = 0
  const r = await request('POST', '/did/create', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'passphrase=hunter2hunter2&passphrase-confirm=hunter2hunter2'
  })
  t.is(r.status, 200)
  const mint = calls.find((c) => c.cmd === 'did-mint')
  t.ok(mint, 'the daemon was asked to mint')
  t.is(mint.args.passphrase, 'hunter2hunter2', 'and the passphrase went through')
  t.ok(r.body.includes('alpha') && r.body.includes('bravo'), 'the words are on the page')
  t.ok(r.body.includes('copy'), 'with a way to copy them that is not retyping 24 words')
})

test('⛔ POST /did/create refuses a mismatched confirmation, and mints nothing', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo', sealed: true }
  calls.length = 0
  const r = await request('POST', '/did/create', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'passphrase=hunter2hunter2&passphrase-confirm=typo'
  })
  t.is(r.status, 303)
  t.ok(decodeURIComponent(r.headers.location || '').includes('do not match'), r.headers.location)
  t.absent(calls.find((c) => c.cmd === 'did-mint'), 'nothing was minted')
})

// ⛔ A failed seal is NOT a failed mint — the daemon deliberately does not throw, because the identity
// exists and the words are on screen. This page is the only thing that can say the backup did not
// happen, so it must.
test('⛔ a mint whose vault could not be sealed says so on the phrase page', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo', sealed: false, sealError: 'disk full' }
  const r = await request('POST', '/did/create', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'passphrase=hunter2hunter2&passphrase-confirm=hunter2hunter2'
  })
  t.is(r.status, 200)
  t.ok(r.body.includes('alpha'), 'the words are still shown — they are all that is left')
  t.ok(/could NOT be written/i.test(r.body), 'and the failure is stated')
  t.ok(r.body.includes('disk full'), 'with the reason: ' + /disk full/.test(r.body))
})

// ⛔ THE IRREVERSIBLE ONE GETS ITS OWN SCREEN. A GET must never submit, and the confirmation has to
// carry the sentence hpp's mint uses: a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log.
test('⛔ GET /did/publish confirms and submits NOTHING', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: false, publishable: true }
  calls.length = 0
  const r = await request('GET', '/did/publish', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(/permanently|cannot be undone/i.test(r.body), 'it says what this costs')
  t.absent(calls.find((c) => c.cmd === 'did-submit'), 'and a GET submits nothing')
})

test('★ POST /did/publish submits, and an already-published identity is not re-posted', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-submit'] = { did: 'did:plc:abc', submitted: true, already: false }
  calls.length = 0
  const r = await request('POST', '/did/publish', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: ''
  })
  t.is(r.status, 303)
  t.ok(calls.find((c) => c.cmd === 'did-submit'), 'the daemon was asked')
  t.ok(decodeURIComponent(r.headers.location || '').includes('published'), r.headers.location)

  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true }
  calls.length = 0
  const again = await request('GET', '/did/publish', { headers: { cookie } })
  t.is(again.status, 303, 'the confirmation is not offered a second time')
  t.absent(calls.find((c) => c.cmd === 'did-submit'), 'and nothing was posted')
})

// ⚠ 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.
test('⚠ a failed publish returns to the confirmation, with the reason', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-submit'] = new Error('plc directory unreachable')
  const r = await request('POST', '/did/publish', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: ''
  })
  t.is(r.status, 303)
  t.ok((r.headers.location || '').startsWith('/did/publish'), 'back to the button: ' + r.headers.location)
  t.ok(decodeURIComponent(r.headers.location || '').includes('unreachable'), 'with the reason')
})

// ⛔ DESIGNATION IS ITS OWN ACT, and it must not switch a network on by accident. identity.md §5.1:
// picking a credential on the author's behalf would publish under an identity they did not choose,
// so minting does not elect one — this does, by name, with a button.
test('★ POST /did/designate elects this node\'s identity and touches nothing else', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: false, publishable: true }
  answers['set-reach'] = { networks: [], credential: 'did:plc:abc' }
  calls.length = 0
  const r = await request('POST', '/did/designate', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: ''
  })
  t.is(r.status, 303)
  const set = calls.find((c) => c.cmd === 'set-reach')
  t.ok(set, 'the daemon was asked')
  t.is(set.args.credential, 'did:plc:abc', 'with this node\'s own identity')
  t.absent('networks' in set.args, 'and NO networks field — setReach merges, so sending one would ' +
    'switch a network on as a side effect of choosing an identity')
})

test('⚠ designate does nothing when the node has no identity', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-status'] = { did: null, sealed: false, submitted: false, publishable: false }
  calls.length = 0
  const r = await request('POST', '/did/designate', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: ''
  })
  t.is(r.status, 303)
  t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was designated')
})

// ── Flow 2 — a name on a bridge ──────────────────────────────────────────────────────────────────
//
// ⛔ WHAT WAS HERE WAS A BOX ASKING YOU TO TYPE THE ANSWER. Recording `name@bridge` performed
// nothing, and the two values a bridge requires — an identity root and a feed key — appear nowhere in
// this UI, so from a handset it could not be obtained at all.
test('★ settings offers to ask a bridge for a name', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.profile = { name: 'x' }
  answers.profiles = {}
  answers.namespace = null
  answers.reach = { networks: [], credential: null, fediverse: null }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('/bridge/claim'), 'a form that asks, rather than a box to transcribe into')
  t.ok(/Fediverse bridge key/.test(r.body), 'and it names WHICH network the key belongs to')
})

test('★★ POST /bridge/claim asks the daemon, then records what the bridge agreed', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['bridge-claim'] = { username: 'alice', acct: 'alice@ap.test', generation: 1, pending: false }
  answers['set-reach'] = {}
  calls.length = 0
  const r = await request('POST', '/bridge/claim', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
  })
  t.is(r.status, 303)
  const claim = calls.find((c) => c.cmd === 'bridge-claim')
  t.ok(claim, 'the daemon was asked')
  t.is(claim.args.name, 'alice')
  t.is(claim.args.bridge, 'b'.repeat(64), 'by key')

  // ⛔ Recorded only AFTER the bridge agreed. Writing it first is exactly what the old text box did.
  const set = calls.find((c) => c.cmd === 'set-reach')
  t.is(set.args.fediverse, 'alice@ap.test')
  t.ok(calls.indexOf(claim) < calls.indexOf(set), 'and in that order')
})

test('⛔ a refusal from the bridge reaches the person verbatim, and records nothing', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['bridge-claim'] = new Error('taken')
  calls.length = 0
  const r = await request('POST', '/bridge/claim', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
  })
  t.is(r.status, 303)
  t.ok(decodeURIComponent(r.headers.location || '').includes('taken'), r.headers.location)
  t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was recorded')
})

// ⚠ 202: claimed but not yet servable. "Done" would promise a dead actor; "failed" would lose a name
// that IS held.
test('⚠ a name claimed but not yet served says both halves', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['bridge-claim'] = { acct: 'alice@ap.test', pending: 'swarm unreachable' }
  answers['set-reach'] = {}
  const r = await request('POST', '/bridge/claim', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
  })
  const m = decodeURIComponent(r.headers.location || '')
  t.ok(/is yours/.test(m) && /could not start serving/.test(m), m)
})

// ⛔ Releasing BURNS the name — the next holder gets a new identity on purpose — so it gets its own
// screen, and a GET must never perform it.
test('⛔ GET /bridge/release confirms and releases NOTHING', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.reach = { networks: [], credential: null, fediverse: 'alice@ap.test' }
  calls.length = 0
  const r = await request('GET', '/bridge/release', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(/permanent/i.test(r.body), 'it says what this costs')
  t.absent(calls.find((c) => c.cmd === 'bridge-release'), 'and a GET releases nothing')
})

test('★ POST /bridge/release uses the remembered bridge, and clears the name', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.reach = { networks: [], credential: null, fediverse: 'alice@ap.test' }
  answers['bridge-release'] = { released: 'alice' }
  answers['set-reach'] = {}
  calls.length = 0
  const r = await request('POST', '/bridge/release', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, body: ''
  })
  t.is(r.status, 303)
  const rel = calls.find((c) => c.cmd === 'bridge-release')
  t.is(rel.args.acct, 'alice@ap.test', 'the daemon looks the key up itself — no re-pasting')
  t.is(calls.find((c) => c.cmd === 'set-reach').args.fediverse, null, 'and the name is cleared')
})

// ⛔ PUBLISHED IS NOT REACHABLE. A mint is P2P-only — the genesis names no handle and no server — so
// a DID can be published to the global directory and resolve for nobody. These pin that the page says
// so, and that the irreversible half is never reached by a GET.
test('★ a published DID with no handle says it resolves for nobody, and offers the fix', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.profile = { name: 'x' }; answers.profiles = {}; answers.namespace = null
  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true, handle: null }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.ok(/Bluesky cannot resolve it/.test(r.body), 'it does not call this finished')
  t.ok(r.body.includes('/did/carry'), 'and offers the step that fixes it')
})

test('★ once carried, it reports the handle rather than offering again', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers.profile = { name: 'x' }; answers.profiles = {}; answers.namespace = null
  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true, handle: 'alice.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
  const r = await request('GET', '/settings', { headers: { cookie } })
  t.ok(/Carried by/.test(r.body))
  t.ok(r.body.includes('alice.7rnx.net'))
  t.absent(r.body.includes('/did/carry'), 'no second offer')
})

test('⛔ GET /did/carry confirms, names what is permanent, and publishes NOTHING', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true, handle: null }
  calls.length = 0
  const r = await request('GET', '/did/carry', { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(/cannot be undone|permanent/i.test(r.body), 'it says which half is permanent')
  t.ok(/passphrase/i.test(r.body), 'and asks for the key that is allowed to change an identity')
  t.absent(calls.find((c) => c.cmd === 'did-carry'), 'a GET does nothing')
})

test('★★ POST /did/carry does both halves in one call', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  answers['did-carry'] = { did: 'did:plc:abc', handle: 'alice.7rnx.net', adopted: true, pending: false }
  calls.length = 0
  const r = await request('POST', '/did/carry', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'bridge=' + 'b'.repeat(64) + '&name=alice&passphrase=hunter2hunter2'
  })
  t.is(r.status, 303)
  const c = calls.find((x) => x.cmd === 'did-carry')
  t.is(c.args.name, 'alice')
  t.is(c.args.passphrase, 'hunter2hunter2', 'the passphrase reaches the daemon, which holds the vault')
  t.ok(decodeURIComponent(r.headers.location || '').includes('alice.7rnx.net'), r.headers.location)
})

test('⛔ a missing passphrase never reaches the daemon', async (t) => {
  answers.whoami = { feed: 'ab', device: 'cd', linked: true }
  calls.length = 0
  const r = await request('POST', '/did/carry', {
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
    body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
  })
  t.is(r.status, 303)
  t.absent(calls.find((x) => x.cmd === 'did-carry'), 'nothing permanent was attempted')
})

// ── deleting my own post ─────────────────────────────────────────────────────────────────────
//
// ⛔ TWO STEPS, deliberately. The delete reaches bsky and the fediverse, and neither can be taken back
// there, so the link opens a page that says so and only its form deletes. A one-press button next to
// like is how a post gets deleted by the thumb that meant to like it.

test('★ GET /delete asks first — a page with the consequences, and nothing deleted yet', async (t) => {
  reset()
  answers = {}
  const target = 'ab'.repeat(32) + '/post/p1'
  const r = await request('GET', '/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/u/x'), { headers: { cookie } })
  t.is(r.status, 200)
  t.ok(r.body.includes('action="/post/delete"'), 'the page holds the form that deletes')
  t.ok(r.body.includes('name="name" value="p1"'), 'naming the record')
  t.ok(/bsky/.test(r.body) && /Mastodon|fediverse/.test(r.body), 'and says where else it is removed from')
  t.is(called('post-delete').length, 0, 'and looking at it deleted nothing')
})

test('★ POST /post/delete reaches the daemon and returns you where you were', async (t) => {
  reset()
  answers = { 'post-delete': { deleted: 'p1', collection: 'post' } }
  const r = await request('POST', '/post/delete', {
    body: 'name=p1&from=' + encodeURIComponent('/u/' + 'ab'.repeat(32)),
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.alike(called('post-delete').map((c) => c.args), [{ name: 'p1' }])
  t.is(r.status, 303)
  t.is(String(r.headers.location), '/u/' + 'ab'.repeat(32))
})

test('⛔ a refused delete is SAID, not swallowed', async (t) => {
  reset()
  answers = { 'post-delete': new Error('no post or reply of yours is named "zz"') }
  const r = await request('POST', '/post/delete', {
    body: 'name=zz&from=%2F',
    headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
  })
  t.is(r.status, 303)
  t.ok(/[?&]m=/.test(String(r.headers.location)), 'the reason travels to the page')
})

test('shut down', async (t) => {
  await new Promise((r) => server.close(r))
  await new Promise((r) => daemon.close(r))
  t.pass('closed')
})