| … | … | @@ -1486,3 +1486,70 @@ export function knocks (rows) { |
| 1486 | 1486 | export function error (message) { |
| 1487 | 1487 | return layout('error', '<p class="warn">' + esc(message) + '</p>') |
| 1488 | 1488 | } |
| 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. | |
| 1499 | export 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. | |
| 1530 | export 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 | } |
| … | … | @@ -280,6 +280,62 @@ export async function route (req, res, ctx) { |
| 280 | 280 | return res.end() |
| 281 | 281 | } |
| 282 | 282 | |
| 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 | ||
| 283 | 339 | if (req.method === 'GET' && path === '/') { |
| 284 | 340 | const rows = await client.call('timeline', { limit: 50 }) |
| 285 | 341 | const un = await client.call('unresolved') |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.49", | |
| 3 | "version": "0.0.50", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -1213,6 +1213,84 @@ test('⚠ settings renders publishing state, and says OFF plainly', async (t) => |
| 1213 | 1213 | t.ok(r.body.includes('cannot be recalled'), 'and the cost is named beside the switch') |
| 1214 | 1214 | }) |
| 1215 | 1215 | |
| 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. | |
| 1222 | test('⛔ 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 | ||
| 1229 | test('★ /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 | ||
| 1239 | test('★ 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. | |
| 1257 | test('⛔ 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 | ||
| 1265 | test('★ 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 | ||
| 1279 | test('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 | ||
| 1216 | 1294 | test('shut down', async (t) => { |
| 1217 | 1295 | await new Promise((r) => server.close(r)) |
| 1218 | 1296 | await new Promise((r) => daemon.close(r)) |