OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / e11bd5e

0.0.50: onboarding — a node with no identity now has a way to get one
The surface fetched `whoami` (which reports `linked`) and never branched on it, so an unlinked node rendered a full timeline and revealed the problem only when a name claim failed and the daemon's error was passed through. There was no `/link`, `/pair` or `/onboard` route anywhere. On a desktop you ran `hyperident` then `favela link`; on a handset there is no shell, so a phone could not become anybody at all. ⛔ THREE DOORS, AND THEY ARE NOT INTERCHANGEABLE. create a new identity restore rebuild one from its recovery phrase — needs NOBODY online pair copy one from a device that IS online, and never handle the phrase Only restore survives losing every device; only pair avoids touching the phrase. Shipping two of the three would strand somebody. ⛔ THE PHRASE IS SHOWN ONCE AND THERE IS NO ROUTE THAT REPEATS IT. A test asserts GET on the minting paths does not render it. If any GET could, "a stolen device yields a locked vault" would stop being true (hyper-social #48) — the absence is the feature, so it is pinned rather than left to habit. ⚠ `identity.linked === false`, NOT falsy. A daemon that does not report the field must not be treated as unlinked and lock its owner out of their own node — absence means OLD, which is not the same as unlinked. Settings already applies that tolerance to a daemon with no `reach`. Learned the hard way: the falsy version redirected all 175 existing tests to /welcome. ⚠ Written after the code rather than before it. The stub's missing `linked` is what surfaced that, and the gate tests exist now because of it. 181/181 tests, 584/584 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e11bd5e1dde0bdb727dc3c092bd298c4fdab6949
by plankatron <fleeky@gmail.com> · 2026-09-10T14:31:30.000Z

Changed files (4) +202 -1

lib/pages.js +67 -0
……@@ -1486,3 +1486,70 @@ export function knocks (rows) {
14861486export function error (message) {
14871487 return layout('error', '<p class="warn">' + esc(message) + '</p>')
14881488}
1489
1490// ── onboarding ───────────────────────────────────────────────────────────────────────────────────
1491//
1492// ⛔ THREE DOORS, AND THEY ARE NOT INTERCHANGEABLE. Create makes a new identity. Restore rebuilds one
1493// from its recovery phrase and needs nobody online. Pair copies one from a device that IS online.
1494// Only restore survives losing every device, and only pair avoids handling the phrase at all — so
1495// offering two of the three would strand somebody.
1496//
1497// ⚠ This is the screen a handset had no way to reach at all: creating an identity used to require
1498// `hyperident` on a machine with a shell.
1499export function welcome (message = null) {
1500 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
1501 return layout('welcome', warn +
1502 '<h1>Welcome to favela</h1>' +
1503 '<p>This node has no identity yet. Everything you post is signed by one, so pick how to get it.</p>' +
1504
1505 '<h2>Create a new identity</h2>' +
1506 '<p class="who">You will be shown a 24-word recovery phrase, once. It is the only way back if ' +
1507 'you lose every device.</p>' +
1508 '<form method="post" action="/welcome/create">' +
1509 '<input type="password" name="passphrase" placeholder="passphrase (optional, encrypts the vault)" autocomplete="new-password">' +
1510 '<button>create</button></form>' +
1511
1512 '<h2>I already have a recovery phrase</h2>' +
1513 '<p class="who">Rebuilds an identity you already own. Works with nothing else switched on.</p>' +
1514 '<form method="post" action="/welcome/restore">' +
1515 '<textarea name="mnemonic" rows="3" placeholder="your 24 words" autocomplete="off"></textarea>' +
1516 '<input type="password" name="passphrase" placeholder="passphrase for the new vault (optional)" autocomplete="new-password">' +
1517 '<button>restore</button></form>' +
1518
1519 '<h2>Link this device to another</h2>' +
1520 '<p class="who">Paste an invite from a device you are already signed in on — run ' +
1521 '<code>favela pair</code> there. Never asks for your recovery phrase.</p>' +
1522 '<form method="post" action="/welcome/pair">' +
1523 '<textarea name="invite" rows="2" placeholder="invite" autocomplete="off"></textarea>' +
1524 '<button>link</button></form>')
1525}
1526
1527// ⛔ SHOWN ONCE, AND THAT IS THE POINT. The daemon returns the phrase from the mint and never stores
1528// it in the clear; there is no route that can show it again. If there were, "a stolen device yields a
1529// locked vault" would stop being true (hyper-social #48), so the absence is the feature.
1530export function minted (mnemonic, restored = false) {
1531 const words = String(mnemonic).split(/\s+/)
1532 const grid = words.map((w, i) => '<li><span class="who">' + (i + 1) + '</span> ' + esc(w) + '</li>').join('')
1533 if (restored) {
1534 return layout('restored', '<h1>Identity restored</h1>' +
1535 '<p>This device is now signed by your identity. Your recovery phrase has not changed.</p>' +
1536 '<p><a href="/">continue</a></p>')
1537 }
1538 return layout('write this down', '<h1>Write these words down</h1>' +
1539 '<p class="warn">This is the only time they will be shown. There is no route that can show them ' +
1540 'again, and nobody — including this node — can recover them for you.</p>' +
1541 '<ol class="mnemonic">' + grid + '</ol>' +
1542 '<p><button id="copy">copy</button> <span id="copied" class="who"></span></p>' +
1543 '<p class="who">Store them somewhere a stolen phone cannot reach. If you set a passphrase, the ' +
1544 'vault on this device is encrypted with it — but the phrase alone is enough to be you.</p>' +
1545 '<form method="get" action="/"><button>I have written them down</button></form>' +
1546 '<script type="module">\n' +
1547 "const w = " + JSON.stringify(words.join(' ')) + ';\n' +
1548 "document.getElementById('copy').addEventListener('click', async (e) => {\n" +
1549 " e.preventDefault()\n" +
1550 // ⚠ 127.0.0.1 is a secure context in Chromium, so clipboard works in the WebView. The fallback is
1551 // not decoration: a viewer that refuses it must still let someone get the words out.
1552 " try { await navigator.clipboard.writeText(w); document.getElementById('copied').textContent = 'copied' }\n" +
1553 " catch { document.getElementById('copied').textContent = 'copy failed — select the words above' }\n" +
1554 '})\n</script>')
1555}
lib/router.js +56 -0
……@@ -280,6 +280,62 @@ export async function route (req, res, ctx) {
280280 return res.end()
281281 }
282282
283 // ── the identity gate ───────────────────────────────────────────────────────────────────────
284 //
285 // ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. The gate above proves who is at the keyboard; this
286 // one asks whether the NODE is anybody yet. They are different questions with different answers,
287 // and conflating them is why an unlinked node used to render a whole timeline and only reveal the
288 // problem when a name claim failed with the daemon's error passed through.
289 //
290 // ⚠ Asked every request, like `browser-status` above and for the same reason: linking happens on
291 // another route (or another device, via pairing) and a cached "not linked" would strand someone on
292 // this page after they had already succeeded.
293 // ⚠ `=== false`, NOT falsy. A daemon that does not report `linked` at all must not be treated as
294 // unlinked and lock its owner out of their own node — the same tolerance settings applies to a
295 // daemon with no `reach` command. The live daemon always returns a boolean; absence means old, and
296 // old is not the same as unlinked.
297 const identity = await client.call('whoami')
298 if (identity.linked === false) {
299 if (req.method === 'GET' && path === '/welcome') {
300 return send(res, pages.welcome(url.searchParams.get('m')))
301 }
302 if (req.method === 'POST' && (path === '/welcome/create' || path === '/welcome/restore')) {
303 const form = await body(req)
304 const restoring = path === '/welcome/restore'
305 if (restoring && !(form.mnemonic || '').trim()) {
306 return seeOther(res, '/welcome?m=' + encodeURIComponent('paste your recovery phrase'))
307 }
308 try {
309 // ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. It is rendered straight into the
310 // response and never written, cached or logged here — there is deliberately no route that
311 // can show it a second time.
312 const r = await client.call('mint-identity', {
313 passphrase: (form.passphrase || '').trim() || null,
314 mnemonic: restoring ? form.mnemonic.trim() : null
315 })
316 return send(res, pages.minted(r.mnemonic, r.restored))
317 } catch (err) {
318 // The daemon's message verbatim — it is the one that knows whether the phrase was invalid,
319 // the vault already existed, or the passphrase was wrong.
320 return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
321 }
322 }
323 if (req.method === 'POST' && path === '/welcome/pair') {
324 const form = await body(req)
325 if (!(form.invite || '').trim()) {
326 return seeOther(res, '/welcome?m=' + encodeURIComponent('paste an invite'))
327 }
328 try {
329 await client.call('pair-accept', { invite: form.invite.trim() })
330 return seeOther(res, '/')
331 } catch (err) {
332 return seeOther(res, '/welcome?m=' + encodeURIComponent(err.message))
333 }
334 }
335 // Everything else is unreachable until this node is somebody.
336 return seeOther(res, '/welcome')
337 }
338
283339 if (req.method === 'GET' && path === '/') {
284340 const rows = await client.call('timeline', { limit: 50 })
285341 const un = await client.call('unresolved')
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.49",
3 "version": "0.0.50",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/routes.test.js +78 -0
……@@ -1213,6 +1213,84 @@ test('⚠ settings renders publishing state, and says OFF plainly', async (t) =>
12131213 t.ok(r.body.includes('cannot be recalled'), 'and the cost is named beside the switch')
12141214})
12151215
1216// ── the identity gate ────────────────────────────────────────────────────────────────────────────
1217//
1218// ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. These are separate questions and the surface used to ask
1219// only the first: an unlinked node rendered a full timeline and revealed the problem only when a name
1220// claim failed. Written after the code rather than before it, which is the wrong order and is why the
1221// stub's missing `linked` field broke 175 tests before this existed.
1222test('⛔ an UNLINKED node can reach nothing but /welcome', async (t) => {
1223 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1224 const r = await request('GET', '/', { headers: { cookie } })
1225 t.is(r.status, 303)
1226 t.is(r.headers.location, '/welcome', 'and it is sent to onboarding, not to an empty timeline')
1227})
1228
1229test('★ /welcome offers all THREE doors — create, restore, and pair', async (t) => {
1230 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1231 const r = await request('GET', '/welcome', { headers: { cookie } })
1232 t.is(r.status, 200)
1233 t.ok(r.body.includes('/welcome/create'), 'create')
1234 // ⛔ Restore is the only door that survives losing every device; pair needs one of them awake.
1235 t.ok(r.body.includes('/welcome/restore'), 'restore')
1236 t.ok(r.body.includes('/welcome/pair'), 'pair')
1237})
1238
1239test('★ POST /welcome/create mints and shows the words ONCE', async (t) => {
1240 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1241 answers['mint-identity'] = { mnemonic: 'alpha bravo charlie delta', device: 'ff', restored: false }
1242 calls.length = 0
1243 const r = await request('POST', '/welcome/create', {
1244 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1245 body: 'passphrase=hunter2'
1246 })
1247 t.is(r.status, 200)
1248 const mint = calls.find((c) => c.cmd === 'mint-identity')
1249 t.ok(mint, 'the daemon was asked to mint')
1250 t.is(mint.args.passphrase, 'hunter2', 'and the passphrase was passed through')
1251 t.is(mint.args.mnemonic, null, 'creating, not restoring')
1252 t.ok(r.body.includes('alpha') && r.body.includes('bravo'), 'the words are on the page')
1253})
1254
1255// ⛔ THE ABSENCE IS THE FEATURE. If any GET could re-render the phrase, "a stolen device yields a
1256// locked vault" would stop being true (hyper-social #48). This asserts there is no such route.
1257test('⛔ there is NO route that shows the recovery phrase a second time', async (t) => {
1258 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1259 for (const p of ['/welcome/create', '/welcome/minted', '/welcome/mnemonic']) {
1260 const r = await request('GET', p, { headers: { cookie } })
1261 t.is(r.status, 303, p + ' does not render the phrase on GET')
1262 }
1263})
1264
1265test('★ POST /welcome/pair takes an invite and never asks for a phrase', async (t) => {
1266 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1267 answers['pair-accept'] = { ok: true }
1268 calls.length = 0
1269 const r = await request('POST', '/welcome/pair', {
1270 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1271 body: 'invite=abc123'
1272 })
1273 t.is(r.status, 303)
1274 const pair = calls.find((c) => c.cmd === 'pair-accept')
1275 t.is(pair.args.invite, 'abc123')
1276 t.absent(calls.find((c) => c.cmd === 'mint-identity'), 'no mint, so no phrase is handled at all')
1277})
1278
1279test('a LINKED node passes straight through, and an old daemon is not locked out', async (t) => {
1280 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1281 answers.timeline = []
1282 answers.unresolved = []
1283 answers.profiles = {}
1284 const r = await request('GET', '/', { headers: { cookie } })
1285 t.not(r.headers.location, '/welcome', 'linked reaches the timeline')
1286
1287 // ⚠ A daemon that does not report `linked` AT ALL must not read as unlinked — absence means old.
1288 answers.whoami = { feed: 'ab', device: 'cd' }
1289 const old = await request('GET', '/', { headers: { cookie } })
1290 t.not(old.headers.location, '/welcome', 'an older daemon still reaches its own node')
1291})
1292
1293
12161294test('shut down', async (t) => {
12171295 await new Promise((r) => server.close(r))
12181296 await new Promise((r) => daemon.close(r))