OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 412dd28

the Bluesky identity stops being a text box and becomes a flow
What stood here was an input you pasted a DID into. It recorded a string and performed nothing — #69 §10.7 filed it as "wired a field nothing reads". Minting existed in the composition and publishing existed in hpp; no surface could reach either, so the page told people to designate an identity somewhere that did not exist. Three costs, three screens, so none is reachable by a mis-tap on the one before: minting reaches no outside service; the recovery phrase is rendered into one POST response and no GET can reproduce it; publishing is a permanent, public, irreversible append to a global log and says so before the button. The card reports state, never a verdict (#69 §3) — the daemon says what is on disk and the page decides what it means — and is absent entirely when the daemon cannot answer did-status, because a surface pointed at a plain favela-peer genuinely cannot mint one. Designation is offered, never silent. identity.md §5.1: "Silently picking one would publish under an identity the author did not choose." So minting does not elect; a button does, and it sends only the credential, because setReach merges and a networks field would switch a network on as a side effect of choosing an identity. The paste box stays, demoted to "a different identity" — designating a DID held elsewhere is a real thing to want. Every passphrase field is now confirmed, here and on /welcome, and confirmed on the server too: a vault passphrase is typed blind and has no reset, and a check that lives only in a script does not run when the script does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412dd28eed758fa005c8d58038aed744acfcfd13
by plankatron <fleeky@gmail.com> · 2026-09-11T15:17:10.000Z

Changed files (3) +436 -10

lib/pages.js +177 -8
……@@ -1530,7 +1530,7 @@ export function themeSettings (current) {
15301530 * ⚠ A plain form. Every other control here works without JavaScript and so does this one: tick,
15311531 * submit, reload. A live toggle would be the first thing on this page that needed a script.
15321532 */
1533export function reachSettings (reach) {
1533export function reachSettings (reach, did = null) {
15341534 if (!reach) return ''
15351535 const on = new Set(Array.isArray(reach.networks) ? reach.networks : [])
15361536 // ⛔ ONE FIELD PER NETWORK. `body()` in the router keeps the last value for a repeated name, so
……@@ -1555,13 +1555,21 @@ export function reachSettings (reach) {
15551555 // ⚠ It is the ATPROTO credential specifically (§5.1: "designated: 'atproto'"), which is why it
15561556 // says DID rather than "identifier". The fediverse equivalent is not a credential at all — §6
15571557 // makes a bridge a RESOURCE you hold a grant on, obtained by registering with one.
1558 const cred = '<p><label>Bluesky identity (DID)<br>' +
1558 // ⚠ DEMOTED WHEN THIS NODE HAS ITS OWN. The identity section below can now create one and elect
1559 // it with a button, so leading with a paste box would put two controls for one decision on one
1560 // page — which is the confusion this whole flow set out to remove. The field stays, because
1561 // designating a DID held somewhere else is a real thing to want and this is the only way to say it.
1562 const ownIdentity = did && did.did
1563 const cred = '<p><label>' + (ownIdentity ? 'Publish under a different identity' : 'Bluesky identity (DID)') + '<br>' +
15591564 '<input type="text" name="credential" value="' + esc(reach.credential || '') + '" ' +
15601565 'placeholder="did:plc:…" autocomplete="off"></label></p>' +
15611566 (reach.credential
15621567 ? '<p class="who">Publishing to Bluesky as <code>' + esc(reach.credential) + '</code></p>'
1563 : '<p class="warn">No Bluesky identity is designated, so nothing is published there even ' +
1564 'with the box ticked. Paste a DID this node holds, or leave it empty to keep it off.</p>')
1568 : ownIdentity
1569 ? '<p class="who">Leave this empty to use the identity below. Nothing is published to Bluesky ' +
1570 'until one is chosen.</p>'
1571 : '<p class="warn">No Bluesky identity is designated, so nothing is published there even ' +
1572 'with the box ticked. Paste a DID this node holds, or leave it empty to keep it off.</p>')
15651573
15661574 // ⛔ A NAME YOU HOLD, RECORDED — not a claim performed here. Asking a bridge for a name means
15671575 // reaching for a projection, which `test/boundary.test.js` forbids this package outright ("the
……@@ -1660,7 +1668,7 @@ const restart = document.getElementById('app-restart')
16601668if (restart) restart.addEventListener('click', (e) => { e.preventDefault(); post('restart') })
16611669`
16621670
1663export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = []) {
1671export function settings (profile, message, handle = null, namespace = null, token = null, theme = null, reach = null, update = null, known = [], did = null) {
16641672 const p = profile || {}
16651673 const note = message ? '<p class="warn">' + esc(message) + '</p>' : ''
16661674 const shown = p.avatar
……@@ -1671,7 +1679,7 @@ export function settings (profile, message, handle = null, namespace = null, tok
16711679 const shownBanner = p.banner
16721680 ? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>'
16731681 : ''
1674 return layout('settings', note + updateSettings(update) + reachSettings(reach) +
1682 return layout('settings', note + updateSettings(update) + reachSettings(reach, did) + didSettings(did, reach) +
16751683 '<h1>Settings</h1>' + shown +
16761684 '<form method="post" action="/settings" enctype="multipart/form-data">' +
16771685 '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
……@@ -1738,14 +1746,14 @@ export function welcome (message = null) {
17381746 '<p class="who">You will be shown a 24-word recovery phrase, once. It is the only way back if ' +
17391747 'you lose every device.</p>' +
17401748 '<form method="post" action="/welcome/create">' +
1741 '<input type="password" name="passphrase" placeholder="passphrase (optional, encrypts the vault)" autocomplete="new-password">' +
1749 passphraseFields('passphrase', { hint: 'Encrypts the vault on this device. Optional, and there is no way to reset it.' }) +
17421750 '<button>create</button></form>' +
17431751
17441752 '<h2>I already have a recovery phrase</h2>' +
17451753 '<p class="who">Rebuilds an identity you already own. Works with nothing else switched on.</p>' +
17461754 '<form method="post" action="/welcome/restore">' +
17471755 '<textarea name="mnemonic" rows="3" placeholder="your 24 words" autocomplete="off"></textarea>' +
1748 '<input type="password" name="passphrase" placeholder="passphrase for the new vault (optional)" autocomplete="new-password">' +
1756 passphraseFields('passphrase', { hint: 'Encrypts the rebuilt vault on this device. Optional.' }) +
17491757 '<button>restore</button></form>' +
17501758
17511759 '<h2>Link this device to another</h2>' +
……@@ -1916,3 +1924,164 @@ export function status ({ me = null, ns = null, following = [], missing = [], pr
19161924 '</table>'
19171925 : ''))
19181926}
1927
1928// ── passphrase entry ─────────────────────────────────────────────────────────────────────────────
1929//
1930// ⛔ ALWAYS TWO BOXES. A passphrase is typed blind and, for a vault, there is nobody to reset it: get
1931// it wrong once here and the recovery secret you sealed is gone with it. The confirmation costs one
1932// field and removes the only unrecoverable typo in the product.
1933//
1934// ⚠ Compared on the SERVER as well as here. The inline check is a courtesy that fails closed if the
1935// script does not run; the route re-checks, because a page cannot be trusted to have.
1936export function passphraseFields (name = 'passphrase', { required = false, hint = null } = {}) {
1937 const n = esc(name)
1938 return '<p><label>' + (required ? 'Passphrase' : 'Passphrase (optional)') + '<br>' +
1939 '<input type="password" name="' + n + '" autocomplete="new-password"' + (required ? ' required' : '') + '></label></p>' +
1940 '<p><label>Confirm passphrase<br>' +
1941 '<input type="password" name="' + n + '-confirm" autocomplete="new-password"' + (required ? ' required' : '') + '></label></p>' +
1942 (hint ? '<p class="who">' + esc(hint) + '</p>' : '')
1943}
1944
1945// ── Flow 1 — the atproto identity ────────────────────────────────────────────────────────────────
1946//
1947// ⛔ THREE STATES, AND IT NAMES THE ONE IT IS IN. What stood here was a text box you pasted a DID
1948// into, which recorded a string and performed nothing — hyper-social #69 §10.7 filed it as "wired a
1949// field nothing reads". Minting exists, publishing exists; neither had a route to it.
1950//
1951// ⛔ AND IT REPORTS STATE, NOT A VERDICT (#69 §3, earned four times): the daemon says what is on
1952// disk — a DID, a sealed vault, a submission — and this decides what to show. A page that printed a
1953// conclusion could not be used to discover the conclusion was wrong.
1954//
1955// ⚠ ABSENT, not empty, when the daemon cannot answer. A surface pointed at a plain favela-peer gets
1956// no `did-status`, and rendering "you have no identity" there would be a claim this page cannot
1957// support — the same tolerance `reachSettings` already applies to a daemon with no `reach`.
1958export function didSettings (did, reach = null) {
1959 if (!did) return ''
1960
1961 if (!did.did) {
1962 return '<h2>Bluesky identity</h2>' +
1963 '<p class="who">You do not have one. Creating it is free and reaches no outside service — ' +
1964 'publishing to the Bluesky network is a separate step you can take later, or never.</p>' +
1965 '<p><a href="/did/create">Create an identity</a></p>'
1966 }
1967
1968 const vault = did.sealed
1969 ? '<p class="who">Your recovery phrase is sealed in a vault on this device.</p>'
1970 : '<p class="warn">No recovery vault is on this device. If you did not write the phrase down, ' +
1971 'this identity cannot be recovered.</p>'
1972
1973 // ⛔ DESIGNATION IS OFFERED, NEVER SILENT. identity.md §5.1: a user may hold several DIDs and the
1974 // signer needs exactly one at sign time, so "several without a designation → refuse to project.
1975 // Silently picking one would publish under an identity the author did not choose." Minting one
1976 // does not elect it; this is the election, and it is one button with a name on it.
1977 //
1978 // ⚠ Absent when `reach` could not be read — the same tolerance the rest of settings applies — and
1979 // absent when this identity is ALREADY the designated one, because an action that changes nothing
1980 // reads as an action that did not work.
1981 const designated = reach && reach.credential === did.did
1982 const designate = !reach
1983 ? ''
1984 : designated
1985 ? '<p class="who">Posts you send to Bluesky are published under this identity.</p>'
1986 : '<form method="post" action="/did/designate">' +
1987 '<button>publish under this identity</button></form>' +
1988 '<p class="who">Nothing is sent to Bluesky until you also switch the network on above.</p>'
1989
1990 if (did.submitted) {
1991 return '<h2>Bluesky identity</h2>' +
1992 '<p>Published as <code>' + esc(did.did) + '</code></p>' + vault + designate
1993 }
1994
1995 return '<h2>Bluesky identity</h2>' +
1996 '<p><code>' + esc(did.did) + '</code></p>' +
1997 '<p class="who">This works between favela peers. The Bluesky network cannot see it yet.</p>' +
1998 vault +
1999 (did.publishable
2000 ? '<p><a href="/did/publish">Publish to the Bluesky network</a></p>'
2001 : '<p class="warn">The genesis operation for this identity is not on this device, so it cannot ' +
2002 'be published from here. It can be re-derived from your recovery phrase.</p>') +
2003 designate
2004}
2005
2006/**
2007 * The confirmation in front of minting.
2008 *
2009 * ⛔ A SEPARATE SCREEN, because the next one shows a secret exactly once and somebody who arrived by
2010 * mis-tapping a button is not ready to write down 24 words.
2011 */
2012export function didCreate (message = null) {
2013 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
2014 return layout('create an identity', warn +
2015 '<h1>Create a Bluesky identity</h1>' +
2016 '<p>This creates an identity that works between favela peers. It reaches no outside service, and ' +
2017 'the Bluesky network will not see it until you publish it — a separate step.</p>' +
2018 '<p class="warn">The next screen shows a recovery phrase. It is shown once, and nothing — ' +
2019 'including this node — can show it to you again.</p>' +
2020 '<form method="post" action="/did/create">' +
2021 passphraseFields('passphrase', {
2022 required: true,
2023 hint: 'At least 8 characters. This encrypts the recovery vault on this device, and it is the ' +
2024 'only thing protecting it. There is no way to reset it.'
2025 }) +
2026 '<button>create</button></form>' +
2027 '<p><a href="/settings">cancel</a></p>')
2028}
2029
2030/**
2031 * The phrase, once.
2032 *
2033 * ⛔ RENDERED INTO THIS RESPONSE AND NOWHERE ELSE. No GET route can reproduce it, exactly as
2034 * `minted()` does for the favela identity — if any could, "a stolen device yields a locked vault"
2035 * would stop being true.
2036 *
2037 * ⚠ It reports whether the seal SUCCEEDED. The mint deliberately does not throw when sealing fails —
2038 * the identity exists and the words are on screen, so a person who writes them down has lost nothing
2039 * — which means this page is the only thing that can tell them the backup did not happen.
2040 */
2041export function didMinted (r) {
2042 const words = String(r.mnemonic || '').split(/\s+/)
2043 const grid = words.map((w, i) => '<li><span class="who">' + (i + 1) + '</span> ' + esc(w) + '</li>').join('')
2044 const seal = r.sealed
2045 ? '<p class="who">A sealed copy is on this device, encrypted with your passphrase. The words above ' +
2046 'are still the only thing that survives losing it.</p>'
2047 : '<p class="warn">The recovery vault could NOT be written' +
2048 (r.sealError ? ' (' + esc(r.sealError) + ')' : '') + '. These words are the only copy — write ' +
2049 'them down before you leave this page.</p>'
2050 return layout('write this down', '<h1>Write these words down</h1>' +
2051 '<p class="warn">This is the only time they will be shown. There is no route that can show them ' +
2052 'again, and nobody — including this node — can recover them for you.</p>' +
2053 '<ol class="mnemonic">' + grid + '</ol>' +
2054 '<p><button id="copy">copy</button> <span id="copied" class="who"></span></p>' +
2055 seal +
2056 '<p class="who">Identity <code>' + esc(r.did || '') + '</code></p>' +
2057 '<form method="get" action="/settings"><button>I have written them down</button></form>' +
2058 '<script type="module">\n' +
2059 'const w = ' + JSON.stringify(words.join(' ')) + ';\n' +
2060 "document.getElementById('copy').addEventListener('click', async (e) => {\n" +
2061 ' e.preventDefault()\n' +
2062 // ⚠ 127.0.0.1 is a secure context in Chromium, so clipboard works in the WebView. The fallback is
2063 // not decoration: a viewer that refuses it must still let someone get the words out.
2064 " try { await navigator.clipboard.writeText(w); document.getElementById('copied').textContent = 'copied' }\n" +
2065 " catch { document.getElementById('copied').textContent = 'copy failed — select the words above' }\n" +
2066 '})\n</script>')
2067}
2068
2069/**
2070 * The confirmation in front of the one irreversible act in this flow.
2071 *
2072 * ⛔ ITS OWN SCREEN AND ITS OWN WORDS. `hyper-proto-peer`'s mint calls this "a PERMANENT, PUBLIC,
2073 * IRREVERSIBLE append to a global log". A person should meet that sentence before the button, not
2074 * after.
2075 */
2076export function didPublish (did, message = null) {
2077 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
2078 return layout('publish identity', warn +
2079 '<h1>Publish to the Bluesky network</h1>' +
2080 '<p>Identity <code>' + esc(did) + '</code></p>' +
2081 '<p class="warn">This appends your identity to a global public directory, permanently. It cannot ' +
2082 'be undone, edited or deleted. Replacing it later leaves the old entry readable for ever.</p>' +
2083 '<p class="who">Until you do this, your identity works between favela peers and the Bluesky ' +
2084 'network cannot see it. Nothing else about favela depends on this step.</p>' +
2085 '<form method="post" action="/did/publish"><button>publish</button></form>' +
2086 '<p><a href="/settings">not now</a></p>')
2087}
lib/router.js +85 -1
……@@ -100,6 +100,11 @@ async function settingsPage (client, message, token = null, theme = null, appUpd
100100 // Passing null makes the section absent rather than showing every network as off, which would be
101101 // a claim this surface cannot support.
102102 const reach = await client.call('reach').catch(() => null)
103 // ⚠ Tolerated missing, and this one is missing in a REAL arrangement rather than only an old one:
104 // a surface pointed at a plain favela-peer daemon gets "unknown command", because the atproto verbs
105 // live in the composition (favela-host) and not in the daemon. Absent means the section does not
106 // render, which is correct — that node genuinely cannot mint one.
107 const did = await client.call('did-status').catch(() => null)
103108 // ⛔ SUPPLIED BY THE EMBEDDER, never read from here. `test/router-import.test.js` asserts this file
104109 // touches no environment — "an embedder has no env to read" — and it is right: a shell writes
105110 // `app-update.json`, its own entrypoint knows where, and this file must not learn what a worklet is.
……@@ -107,7 +112,7 @@ async function settingsPage (client, message, token = null, theme = null, appUpd
107112 // render at all rather than being fabricated from what this surface can see.
108113 let update = null
109114 try { update = appUpdate ? await appUpdate() : null } catch { /* stays absent rather than guessing */ }
110 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known)
115 return pages.settings(await client.call('profile'), message, mine.handle || null, ns, token, theme, reach, update, known, did)
111116}
112117
113118/**
……@@ -322,6 +327,12 @@ export async function route (req, res, ctx) {
322327 if (restoring && !(form.mnemonic || '').trim()) {
323328 return seeOther(res, '/welcome?m=' + encodeURIComponent('paste your recovery phrase'))
324329 }
330 // ⛔ Same rule as /did/create, and for the same reason: a vault passphrase is typed blind and
331 // cannot be reset, so it is confirmed — and confirmed on the server, because a check that
332 // lives only in a script does not run when the script does not.
333 if (String(form.passphrase || '') !== String(form['passphrase-confirm'] || '')) {
334 return seeOther(res, '/welcome?m=' + encodeURIComponent('the two passphrases do not match'))
335 }
325336 try {
326337 // ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. It is rendered straight into the
327338 // response and never written, cached or logged here — there is deliberately no route that
……@@ -847,6 +858,79 @@ export async function route (req, res, ctx) {
847858 return seeOther(res, '/settings')
848859 }
849860
861 // ── Flow 1 — the atproto identity ──────────────────────────────────────────────────────────────
862 //
863 // ⛔ THREE ROUTES FOR THREE COSTS. Minting reaches nothing and is free; showing the phrase is the
864 // one moment a secret exists outside the vault; publishing is permanent and public. Each gets its
865 // own screen so none of them can be reached by a mis-tap on the one before.
866
867 if (req.method === 'GET' && path === '/did/create') {
868 return send(res, pages.didCreate(url.searchParams.get('m')))
869 }
870
871 if (req.method === 'POST' && path === '/did/create') {
872 const form = await body(req)
873 const pass = String(form.passphrase || '')
874 // ⛔ CHECKED HERE TOO, not only in the browser. The confirmation field exists because a vault
875 // passphrase is typed blind and has no reset — a mistyped one seals the recovery secret away
876 // for good — and a check that lives only in a script is a check that does not run when the
877 // script does not.
878 if (pass !== String(form['passphrase-confirm'] || '')) {
879 return seeOther(res, '/did/create?m=' + encodeURIComponent('the two passphrases do not match'))
880 }
881 try {
882 // ⛔ THE ONLY TIME THE PHRASE EXISTS OUTSIDE THE VAULT. Rendered straight into this response
883 // and never written, cached or logged here — there is deliberately no route that shows it
884 // again. Same contract as /welcome/create.
885 const r = await client.call('did-mint', { passphrase: pass })
886 return send(res, pages.didMinted(r))
887 } catch (err) {
888 // The daemon's message verbatim: it is the one that knows whether the passphrase was too
889 // short, an identity already existed, or the config could not be written.
890 return seeOther(res, '/did/create?m=' + encodeURIComponent(err.message))
891 }
892 }
893
894 // ⛔ DESIGNATION, and it is its own act. identity.md §5.1 gives the rule: a signer needs exactly
895 // one credential, and picking one on the author's behalf would publish under an identity they did
896 // not choose. Minting does not elect; this does.
897 //
898 // ⚠ Sends ONLY the credential. `setReach` merges — "a write naming only `networks` leaves the
899 // credential alone, and the reverse" — so this cannot switch a network on as a side effect, which
900 // is the one thing it must never do.
901 if (req.method === 'POST' && path === '/did/designate') {
902 const st = await client.call('did-status').catch(() => null)
903 if (!st || !st.did) return seeOther(res, '/settings')
904 try {
905 await client.call('set-reach', { credential: st.did })
906 } catch (err) {
907 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
908 }
909 return seeOther(res, '/settings?m=' + encodeURIComponent(
910 'Bluesky posts will be published under ' + st.did))
911 }
912
913 if (req.method === 'GET' && path === '/did/publish') {
914 const st = await client.call('did-status').catch(() => null)
915 if (!st || !st.did) return seeOther(res, '/settings')
916 if (st.submitted) {
917 return seeOther(res, '/settings?m=' + encodeURIComponent('that identity is already published'))
918 }
919 return send(res, pages.didPublish(st.did, url.searchParams.get('m')))
920 }
921
922 if (req.method === 'POST' && path === '/did/publish') {
923 try {
924 const r = await client.call('did-submit', {})
925 return seeOther(res, '/settings?m=' + encodeURIComponent(
926 r.already ? 'that identity was already published' : 'published to the Bluesky network'))
927 } catch (err) {
928 // ⚠ Back to the CONFIRMATION, not to settings. Submitting reaches the network and the common
929 // failure is transient, so the page someone lands on should be the one with the button.
930 return seeOther(res, '/did/publish?m=' + encodeURIComponent(err.message))
931 }
932 }
933
850934 /**
851935 * Where this identity publishes.
852936 *
test/routes.test.js +174 -1
……@@ -1242,7 +1242,7 @@ test('★ POST /welcome/create mints and shows the words ONCE', async (t) => {
12421242 calls.length = 0
12431243 const r = await request('POST', '/welcome/create', {
12441244 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1245 body: 'passphrase=hunter2'
1245 body: 'passphrase=hunter2&passphrase-confirm=hunter2'
12461246 })
12471247 t.is(r.status, 200)
12481248 const mint = calls.find((c) => c.cmd === 'mint-identity')
……@@ -1252,6 +1252,23 @@ test('★ POST /welcome/create mints and shows the words ONCE', async (t) => {
12521252 t.ok(r.body.includes('alpha') && r.body.includes('bravo'), 'the words are on the page')
12531253})
12541254
1255// ⛔ A VAULT PASSPHRASE IS TYPED BLIND AND CANNOT BE RESET, so it is confirmed — and confirmed HERE,
1256// not only in the browser, because a check that lives in a script does not run when the script does
1257// not. A mistyped one seals the recovery secret away for good.
1258test('⛔ POST /welcome/create refuses when the two passphrases differ, and mints nothing', async (t) => {
1259 answers.whoami = { feed: 'ab', device: 'cd', linked: false }
1260 answers['mint-identity'] = { mnemonic: 'alpha bravo charlie delta', device: 'ff', restored: false }
1261 calls.length = 0
1262 const r = await request('POST', '/welcome/create', {
1263 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1264 body: 'passphrase=hunter2&passphrase-confirm=hunter3'
1265 })
1266 t.is(r.status, 303, 'sent back to the form')
1267 // ⚠ Decoded: the message rides in the query string, so a raw regex sees %20 and not spaces.
1268 t.ok(decodeURIComponent(r.headers.location || '').includes('do not match'), 'and told why: ' + r.headers.location)
1269 t.absent(calls.find((c) => c.cmd === 'mint-identity'), 'nothing was minted')
1270})
1271
12551272// ⛔ THE ABSENCE IS THE FEATURE. If any GET could re-render the phrase, "a stolen device yields a
12561273// locked vault" would stop being true (hyper-social #48). This asserts there is no such route.
12571274test('⛔ there is NO route that shows the recovery phrase a second time', async (t) => {
……@@ -1323,6 +1340,162 @@ test('an empty credential box CLEARS the designation rather than being ignored',
13231340 t.is(calls.find((c) => c.cmd === 'set-reach').args.credential, null)
13241341})
13251342
1343// ── Flow 1 — the atproto identity ────────────────────────────────────────────────────────────────
1344//
1345// ⛔ WHAT WAS HERE WAS A TEXT BOX. `reachSettings` rendered "Bluesky identity (DID)" and you pasted a
1346// string into it; minting existed in the composition and publishing existed in hpp, and nothing on
1347// any surface could reach either. hyper-social #69 §10.7 filed it as "wired a field nothing reads".
1348//
1349// ⛔ THE THREE COSTS ARE THREE ROUTES, and these pin that they stay three: minting reaches nothing,
1350// the phrase is shown exactly once, and publishing is permanent and public.
1351
1352test('★ settings renders the identity card from did-status', async (t) => {
1353 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1354 answers.profile = { name: 'x' }
1355 answers.profiles = {}
1356 answers.namespace = null
1357 answers['did-status'] = { did: null, sealed: false, submitted: false, publishable: false }
1358 const r = await request('GET', '/settings', { headers: { cookie } })
1359 t.is(r.status, 200)
1360 t.ok(r.body.includes('/did/create'), 'a route to create one exists on the page')
1361})
1362
1363// ⚠ A surface pointed at a plain favela-peer gets "unknown command" for did-status, because the
1364// atproto verbs live in the composition and not in the daemon. That node genuinely cannot mint one,
1365// so the section must be ABSENT rather than claiming there is no identity.
1366test('⚠ a daemon that cannot answer did-status renders no identity section at all', async (t) => {
1367 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1368 answers.profile = { name: 'x' }
1369 answers.profiles = {}
1370 answers.namespace = null
1371 answers['did-status'] = new Error('unknown command: did-status')
1372 const r = await request('GET', '/settings', { headers: { cookie } })
1373 t.is(r.status, 200, 'settings still renders')
1374 t.absent(r.body.includes('/did/create'), 'and offers nothing it cannot do')
1375})
1376
1377test('★ POST /did/create mints and shows the words ONCE', async (t) => {
1378 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1379 answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo charlie delta', sealed: true }
1380 calls.length = 0
1381 const r = await request('POST', '/did/create', {
1382 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1383 body: 'passphrase=hunter2hunter2&passphrase-confirm=hunter2hunter2'
1384 })
1385 t.is(r.status, 200)
1386 const mint = calls.find((c) => c.cmd === 'did-mint')
1387 t.ok(mint, 'the daemon was asked to mint')
1388 t.is(mint.args.passphrase, 'hunter2hunter2', 'and the passphrase went through')
1389 t.ok(r.body.includes('alpha') && r.body.includes('bravo'), 'the words are on the page')
1390 t.ok(r.body.includes('copy'), 'with a way to copy them that is not retyping 24 words')
1391})
1392
1393test('⛔ POST /did/create refuses a mismatched confirmation, and mints nothing', async (t) => {
1394 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1395 answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo', sealed: true }
1396 calls.length = 0
1397 const r = await request('POST', '/did/create', {
1398 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1399 body: 'passphrase=hunter2hunter2&passphrase-confirm=typo'
1400 })
1401 t.is(r.status, 303)
1402 t.ok(decodeURIComponent(r.headers.location || '').includes('do not match'), r.headers.location)
1403 t.absent(calls.find((c) => c.cmd === 'did-mint'), 'nothing was minted')
1404})
1405
1406// ⛔ A failed seal is NOT a failed mint — the daemon deliberately does not throw, because the identity
1407// exists and the words are on screen. This page is the only thing that can say the backup did not
1408// happen, so it must.
1409test('⛔ a mint whose vault could not be sealed says so on the phrase page', async (t) => {
1410 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1411 answers['did-mint'] = { did: 'did:plc:abc', mnemonic: 'alpha bravo', sealed: false, sealError: 'disk full' }
1412 const r = await request('POST', '/did/create', {
1413 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1414 body: 'passphrase=hunter2hunter2&passphrase-confirm=hunter2hunter2'
1415 })
1416 t.is(r.status, 200)
1417 t.ok(r.body.includes('alpha'), 'the words are still shown — they are all that is left')
1418 t.ok(/could NOT be written/i.test(r.body), 'and the failure is stated')
1419 t.ok(r.body.includes('disk full'), 'with the reason: ' + /disk full/.test(r.body))
1420})
1421
1422// ⛔ THE IRREVERSIBLE ONE GETS ITS OWN SCREEN. A GET must never submit, and the confirmation has to
1423// carry the sentence hpp's mint uses: a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log.
1424test('⛔ GET /did/publish confirms and submits NOTHING', async (t) => {
1425 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1426 answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: false, publishable: true }
1427 calls.length = 0
1428 const r = await request('GET', '/did/publish', { headers: { cookie } })
1429 t.is(r.status, 200)
1430 t.ok(/permanently|cannot be undone/i.test(r.body), 'it says what this costs')
1431 t.absent(calls.find((c) => c.cmd === 'did-submit'), 'and a GET submits nothing')
1432})
1433
1434test('★ POST /did/publish submits, and an already-published identity is not re-posted', async (t) => {
1435 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1436 answers['did-submit'] = { did: 'did:plc:abc', submitted: true, already: false }
1437 calls.length = 0
1438 const r = await request('POST', '/did/publish', {
1439 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1440 body: ''
1441 })
1442 t.is(r.status, 303)
1443 t.ok(calls.find((c) => c.cmd === 'did-submit'), 'the daemon was asked')
1444 t.ok(decodeURIComponent(r.headers.location || '').includes('published'), r.headers.location)
1445
1446 answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true }
1447 calls.length = 0
1448 const again = await request('GET', '/did/publish', { headers: { cookie } })
1449 t.is(again.status, 303, 'the confirmation is not offered a second time')
1450 t.absent(calls.find((c) => c.cmd === 'did-submit'), 'and nothing was posted')
1451})
1452
1453// ⚠ Back to the confirmation, not to settings: submitting reaches the network and the common failure
1454// is transient, so the page someone lands on should be the one with the button.
1455test('⚠ a failed publish returns to the confirmation, with the reason', async (t) => {
1456 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1457 answers['did-submit'] = new Error('plc directory unreachable')
1458 const r = await request('POST', '/did/publish', {
1459 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1460 body: ''
1461 })
1462 t.is(r.status, 303)
1463 t.ok((r.headers.location || '').startsWith('/did/publish'), 'back to the button: ' + r.headers.location)
1464 t.ok(decodeURIComponent(r.headers.location || '').includes('unreachable'), 'with the reason')
1465})
1466
1467// ⛔ DESIGNATION IS ITS OWN ACT, and it must not switch a network on by accident. identity.md §5.1:
1468// picking a credential on the author's behalf would publish under an identity they did not choose,
1469// so minting does not elect one — this does, by name, with a button.
1470test('★ POST /did/designate elects this node\'s identity and touches nothing else', async (t) => {
1471 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1472 answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: false, publishable: true }
1473 answers['set-reach'] = { networks: [], credential: 'did:plc:abc' }
1474 calls.length = 0
1475 const r = await request('POST', '/did/designate', {
1476 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1477 body: ''
1478 })
1479 t.is(r.status, 303)
1480 const set = calls.find((c) => c.cmd === 'set-reach')
1481 t.ok(set, 'the daemon was asked')
1482 t.is(set.args.credential, 'did:plc:abc', 'with this node\'s own identity')
1483 t.absent('networks' in set.args, 'and NO networks field — setReach merges, so sending one would ' +
1484 'switch a network on as a side effect of choosing an identity')
1485})
1486
1487test('⚠ designate does nothing when the node has no identity', async (t) => {
1488 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1489 answers['did-status'] = { did: null, sealed: false, submitted: false, publishable: false }
1490 calls.length = 0
1491 const r = await request('POST', '/did/designate', {
1492 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1493 body: ''
1494 })
1495 t.is(r.status, 303)
1496 t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was designated')
1497})
1498
13261499test('shut down', async (t) => {
13271500 await new Promise((r) => server.close(r))
13281501 await new Promise((r) => daemon.close(r))