// ★ The worklet boot, exercised OFF-DEVICE.
//
// Everything here runs without bare-kit, without Android and without an emulator. That is the point
// of the split in `lib/node.js`: `serveSurface(node)` takes a node and stands up the real routes
// over real HTTP, so the wiring that decides whether a phone shows anything is testable on a laptop.
//
// ⛔ What this proves is the one architectural claim the port rests on: favela-surface can be served
// from favela-peer with NO SOCKET IN EXISTENCE. `favela-peer/lib/rpc.js` exports `dispatch` next to
// `createServer`; `dispatch` is the interface and the socket is one adapter over it. A pathname unix
// socket could not be created on Android at all (SELinux refuses an unprivileged app a `sock_file`),
// so if this did not work the port would not be possible in this shape.
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import { join } from 'bare-path'
import http from 'bare-http1'
import b4a from 'b4a'
import Hyperswarm from 'hyperswarm'
import { FavelaNode } from 'favela-peer/lib/node.js'
import { Sessions } from 'favela-surface/lib/session.js'
import { serveSurface, inProcessClient, signIn } from '../lib/node.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'fsa-'))
function get (port, path, headers = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path, headers }, (res) => {
let body = ''
res.on('data', (c) => { body += c.toString() })
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
})
req.on('error', reject)
req.end()
})
}
async function bootNode (t) {
// ⚠ No swarm join: this test is about the surface-to-node wiring, and a DHT would add real network
// timing to something that is not about the network.
const swarm = new Hyperswarm()
const node = new FavelaNode(tmp(), { transport: null, swarm })
await node.ready()
t.teardown(async () => {
try { await node.close() } catch {}
try { await swarm.destroy() } catch {}
})
return node
}
test('★ the surface serves from an in-process node, with NO socket', async (t) => {
const node = await bootNode(t)
const s = await serveSurface(node, { port: 0 })
t.teardown(() => new Promise((r) => s.server.close(r)))
t.ok(s.port > 0, 'the OS chose a port and we know which')
t.is(s.url, 'http://127.0.0.1:' + s.port, 'loopback only — a phone must not answer on Wi-Fi')
// The login page is the one route reachable without a session, so it proves the whole path:
// HTTP -> route -> in-process client -> dispatch -> the real node.
const r = await get(s.port, '/login')
t.is(r.status, 200, 'a real page, rendered from a real node')
t.ok(r.body.includes('<!doctype html'), 'and it is HTML')
})
test('the in-process client IS the socket client\'s shape', async (t) => {
const node = await bootNode(t)
const c = inProcessClient(node)
t.is(typeof c.call, 'function', 'one method, same signature favela-surface\'s Client has')
// ⛔ Reaching the daemon's own handler, not a reimplementation of it.
const who = await c.call('whoami')
t.is(who.feed, node.key.toString('hex'), 'and it answers with this node\'s own feed')
const ns = await c.call('namespace')
t.ok(/^[0-9a-f]{64}$/.test(ns.key), 'namespace too — the whole surface is reachable')
})
test('⛔ a daemon error surfaces as an error, not as an empty page', async (t) => {
const node = await bootNode(t)
const c = inProcessClient(node)
// An unlinked node cannot claim a name, and must say so rather than appear to succeed.
await t.exception(c.call('claim-name', { name: 'alice' }), /credential|linked/i)
})
test('every page is still gated — one process does not mean no session', async (t) => {
const node = await bootNode(t)
const s = await serveSurface(node, { port: 0 })
t.teardown(() => new Promise((r) => s.server.close(r)))
// ⚠ Running in one process is not an authorisation. A WebView is still a browser and the gate is
// still the gate; the handset gets a session the same way anything else does.
for (const p of ['/', '/settings', '/following']) {
const r = await get(s.port, p)
t.is(r.status, 303, p + ' redirects to login')
}
})
test('★ a session minted directly by the worklet works — no /auth/handoff needed', async (t) => {
const node = await bootNode(t)
const sessions = new Sessions()
const s = await serveSurface(node, { port: 0, sessions })
t.teardown(() => new Promise((r) => s.server.close(r)))
// ⛔ THIS is why `serveSurface` takes the session store. favela-android had to add an
// `/auth/handoff` ROUTE to favela-web so a worklet could hand its WebView a cookie. Here the
// worklet owns the `Sessions` object outright, so it mints one and there is no route to add,
// no token to swap, and no second code path to keep honest.
const key = 'ab'.repeat(32)
const id = sessions.create(key)
const r = await get(s.port, '/', { cookie: 'favela_session=' + id })
// ⚠ The gate asks the DAEMON whether the key is still allowed, and this one was never registered
// with it — so the correct answer is still "no". Minting a session is not the same as being
// known, and the worklet must register the app's key before this becomes a 200.
t.is(r.status, 303, 'a minted session is not a credential — the daemon is still asked')
t.ok(decodeURIComponent(String(r.headers.location)).includes('revoked'),
'and it says the browser is not known, which is true')
})
test('★ the app claims its own node on first run, then AUTHENTICATES on the next', async (t) => {
const storage = tmp()
const swarm = new Hyperswarm()
const node = new FavelaNode(storage, { transport: null, swarm })
await node.ready()
t.teardown(async () => {
try { await node.close() } catch {}
try { await swarm.destroy() } catch {}
})
// ⛔ TOFU: with nobody registered, the first key to prove itself claims the instance. This is the
// fresh-install case, and favela-peer refuses an unknown key otherwise (`autoRegister: false`).
const first = await signIn(node, storage)
t.ok(first.claimed, 'the first run claims')
t.is(first.publicKey.length, 44, 'an anproto public key')
// ⛔ And the SECOND run must not try to claim again — TOFU closes on the first claim and never
// reopens, not even for the claimant. A key regenerated each boot would lock this app out of its
// own node permanently, which is why the keypair is on disk.
const second = await signIn(node, storage)
t.absent(second.claimed, 'the second run authenticates instead')
t.is(second.publicKey, first.publicKey, 'as the SAME key, read back from disk')
// And the daemon now vouches for it — which is what the session gate asks on every request.
const st = await inProcessClient(node).call('browser-status', { publicKey: first.publicKey })
t.alike(st, { known: true, revoked: false }, 'known and not revoked')
})
test('★ end to end: a signed-in app loads a real PAGE, not a login redirect', async (t) => {
const storage = tmp()
const swarm = new Hyperswarm()
const node = new FavelaNode(storage, { transport: null, swarm })
await node.ready()
t.teardown(async () => {
try { await node.close() } catch {}
try { await swarm.destroy() } catch {}
})
const who = await signIn(node, storage)
const sessions = new Sessions()
const s = await serveSurface(node, { port: 0, sessions })
t.teardown(() => new Promise((r) => s.server.close(r)))
// The handoff the shell performs: mint a session for the app's key, then a ONE-TIME token, then
// let the redirect plant the HttpOnly cookie the WebView cannot be given any other way.
const id = sessions.create(who.publicKey)
const token = sessions.createHandoff(id)
const hand = await get(s.port, '/auth/handoff?token=' + token + '&next=/')
t.is(hand.status, 303, 'the handoff redirects')
const cookie = String(hand.headers['set-cookie']).split(';')[0]
// ⛔ A FRESH NODE IS UNLINKED, and that is now a state the surface has an answer for. This used to
// assert 200 here and it was right to fail when onboarding landed: a node with no identity rendering
// a timeline is exactly the defect the /welcome gate exists to remove. Signed in as a BROWSER and
// being SOMEBODY are different questions.
const gated = await get(s.port, '/', { cookie })
t.is(gated.status, 303, 'a node with no identity is sent to onboarding')
t.is(gated.headers.location, '/welcome', 'and onboarding is where it goes')
const welcome = await get(s.port, '/welcome', { cookie })
t.is(welcome.status, 200, '★ a real page — the app is signed in to its own node')
t.ok(welcome.body.includes('/welcome/create'), 'offering to create an identity')
t.ok(welcome.body.includes('/welcome/restore'), 'or restore one from a recovery phrase')
// ★ MINT FOR REAL, against the live daemon rather than a stub — the whole point of this file. This
// is the path a handset takes on first run and it could not be taken at all until now.
const { mintIdentity } = await import('favela-peer/lib/mint.js')
const r = await mintIdentity({ vaultPath: join(storage, 'identity', 'vault'), passphrase: 'pw' })
t.is(r.mnemonic.split(' ').length, 24, 'a real 24-word phrase came back')
await node.link({
keyPair: {
publicKey: b4a.from(r.credential.publicKey, 'hex'),
secretKey: b4a.from(r.credential.secretKey, 'hex')
},
proof: r.credential.proof
})
const page = await get(s.port, '/', { cookie })
t.is(page.status, 200, '★ and once it is somebody, the timeline loads')
t.ok(page.body.includes('<!doctype html'), 'and it is the timeline, not a redirect')
// ⛔ One-time really is one-time, on the path that matters.
const replay = await get(s.port, '/auth/handoff?token=' + token)
t.is(replay.status, 303)
t.ok(decodeURIComponent(String(replay.headers.location)).includes('already used'))
})