OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 47e377b

the fediverse box stops asking you to type the answer
It recorded `name@bridge` and performed nothing — and the two values a bridge actually requires, an identity root and a feed key, appear nowhere in this UI. So from a handset a name could not be obtained at all; the only route was a hand-written HTTP call using values read off a desktop CLI. Now the page collects a bridge key and a name, and `bridge-claim` does the ceremony: fetch a challenge, sign bytes binding this name and this feed, send both. The credential never leaves the daemon — identity.md §7.5, "the page renders the form; the peer performs the op". ⛔ By KEY, not hostname. A squatter on a name-derived topic cannot steal a claim, but it can answer "registered!" and leave this node recording a name that does not exist. ⛔ Recorded only after the bridge agreed, which is precisely what the old box did not do. A 202 says both halves — the name is yours, and it is not being served yet — because "done" would promise a dead actor and "failed" would lose a name that is held. Releasing gets its own screen: the bridge burns the name so the next holder cannot inherit your audience, which also means you cannot get it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47e377b6ee034ca613ab0ab57967b89b1f52ca14
by plankatron <fleeky@gmail.com> · 2026-09-11T16:25:05.000Z

Changed files (3) +210 -8

lib/pages.js +68 -8
……@@ -1579,13 +1579,14 @@ export function reachSettings (reach, did = null) {
15791579 // ⚠ It is deliberately NOT beside the DID field. §5.1's `credential` is the designated ATPROTO one
15801580 // and the two networks are not symmetric: on bsky you hold the key, on the fediverse a bridge
15811581 // holds it and you hold a grant. One input each, labelled for what it actually is.
1582 const fedi = '<p><label>Fediverse name<br>' +
1583 '<input type="text" name="fediverse" value="' + esc(reach.fediverse || '') + '" ' +
1584 'placeholder="name@bridge" autocomplete="off"></label></p>' +
1585 (reach.fediverse
1586 ? '<p class="who">Publishing to the fediverse as <code>' + esc(reach.fediverse) + '</code></p>'
1587 : '<p class="who">A bridge serves your feed under this name. It holds the signing key; you ' +
1588 'hold the name — which is why nothing secret goes here.</p>')
1582 // ⚠ A NOTE, NOT A CONTROL — and it says so now. This box asked you to TYPE a name you had already
1583 // obtained somewhere else, and there was no somewhere else: the values a bridge needs (an identity
1584 // root and a feed key) are not shown anywhere in this UI, so from a handset it could not be done at
1585 // all. Asking a bridge is now its own section below; this stays only to record a name held on a
1586 // bridge this node cannot reach.
1587 const fedi = reach.fediverse
1588 ? '<p class="who">Publishing to the fediverse as <code>' + esc(reach.fediverse) + '</code></p>'
1589 : ''
15891590
15901591 return '<h2>Publishing</h2>' +
15911592 (on.size === 0
……@@ -1679,7 +1680,7 @@ export function settings (profile, message, handle = null, namespace = null, tok
16791680 const shownBanner = p.banner
16801681 ? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>'
16811682 : ''
1682 return layout('settings', note + updateSettings(update) + reachSettings(reach, did) + didSettings(did, reach) +
1683 return layout('settings', note + updateSettings(update) + reachSettings(reach, did) + didSettings(did, reach) + bridgeSettings(reach) +
16831684 '<h1>Settings</h1>' + shown +
16841685 '<form method="post" action="/settings" enctype="multipart/form-data">' +
16851686 '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
……@@ -2085,3 +2086,62 @@ export function didPublish (did, message = null) {
20852086 '<form method="post" action="/did/publish"><button>publish</button></form>' +
20862087 '<p><a href="/settings">not now</a></p>')
20872088}
2089
2090// ── Flow 2 — a name on a bridge ──────────────────────────────────────────────────────────────────
2091//
2092// ⛔ WHAT WAS HERE WAS A BOX THAT ASKED YOU TO TYPE THE ANSWER. Recording `name@bridge` performed
2093// nothing, and the two values a bridge actually requires — an identity root and a feed key — appear
2094// nowhere in this UI, so from a handset there was no way to obtain one at all. The only route was a
2095// hand-written HTTP call using values read off a desktop CLI.
2096//
2097// ⛔ THE PEER ASKS, NOT THIS PAGE. `bridge-claim` fetches a challenge, signs bytes that bind this
2098// name and this feed, and sends both; the daemon holds the credential and this page never sees one.
2099// It is also deliberately generic — the daemon is not told what a fediverse is, so the same ceremony
2100// serves any bridge.
2101//
2102// ⛔ BY KEY, NOT HOSTNAME. A topic derived from a name is squattable, and while a squatter cannot
2103// steal a claim — the root and feed key are public and the proof is bound to a single-use nonce — it
2104// CAN answer "registered!" and leave this node recording a name that does not exist.
2105export function bridgeSettings (reach) {
2106 if (!reach) return ''
2107 const held = reach.fediverse
2108
2109 if (held) {
2110 return '<h2>Bridge</h2>' +
2111 '<p>Your feed is carried as <code>' + esc(held) + '</code></p>' +
2112 '<p class="who">A bridge holds the signing key for that network and serves your feed under this ' +
2113 'name. You hold the name; it holds the delivery.</p>' +
2114 '<p><a href="/bridge/release">Give up this name</a></p>'
2115 }
2116
2117 return '<h2>Bridge</h2>' +
2118 '<p class="who">A bridge carries your posts onto a network favela does not speak. Ask one for a ' +
2119 'name and it will hold your feed under it.</p>' +
2120 '<form method="post" action="/bridge/claim">' +
2121 '<p><label>Bridge key<br><input type="text" name="bridge" placeholder="64 hex characters" ' +
2122 'autocomplete="off"></label></p>' +
2123 '<p class="who">The bridge prints this when it starts. A key rather than an address, so the ' +
2124 'answer can only come from the bridge you meant.</p>' +
2125 '<p><label>Name you want<br><input type="text" name="name" placeholder="alice" ' +
2126 'autocomplete="off"></label></p>' +
2127 '<p><label>Invite (only if the bridge is closed)<br><input type="text" name="invite" ' +
2128 'placeholder="leave empty on an open bridge" autocomplete="off"></label></p>' +
2129 '<button>ask for this name</button></form>'
2130}
2131
2132/**
2133 * ⛔ ITS OWN SCREEN, because a release cannot be undone. The bridge burns the name: the next holder
2134 * gets a NEW identity on purpose, so the followers of the old one do not transfer — which is what
2135 * stops name reuse becoming impersonation, and also means there is no way back for you.
2136 */
2137export function bridgeRelease (name, message = null) {
2138 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
2139 return layout('give up this name', warn +
2140 '<h1>Give up ' + esc(name) + '?</h1>' +
2141 '<p class="warn">This is permanent. The name returns to the pool, and if someone else takes it ' +
2142 'they get a NEW identity — your followers do not transfer to them, and re-claiming it would not ' +
2143 'give you yours back either.</p>' +
2144 '<p class="who">Anyone already following you on that network stops receiving your posts.</p>' +
2145 '<form method="post" action="/bridge/release"><button>give it up</button></form>' +
2146 '<p><a href="/settings">keep it</a></p>')
2147}
lib/router.js +50 -0
……@@ -858,6 +858,56 @@ export async function route (req, res, ctx) {
858858 return seeOther(res, '/settings')
859859 }
860860
861 // ── Flow 2 — a name on a bridge ────────────────────────────────────────────────────────────────
862 //
863 // ⛔ THE PEER PERFORMS IT. This page collects a key and a name; `bridge-claim` fetches a challenge,
864 // signs bytes binding both, and sends them. The credential never leaves the daemon and this file
865 // never sees one — identity.md §7.5: "favela-web renders the form; the peer performs the op".
866 if (req.method === 'POST' && path === '/bridge/claim') {
867 const form = await body(req)
868 const bridge = String(form.bridge || '').trim()
869 const name = String(form.name || '').trim()
870 if (!bridge || !name) {
871 return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge key and a name are both needed'))
872 }
873 try {
874 const r = await client.call('bridge-claim', { bridge, name, invite: String(form.invite || '').trim() || null })
875 // ⛔ RECORDED ONLY AFTER THE BRIDGE AGREED. Writing the name first would leave this node
876 // claiming an address it does not hold — the exact thing the old text box did every time.
877 if (r.acct) await client.call('set-reach', { fediverse: r.acct }).catch(() => {})
878 // ⚠ 202 means claimed but not yet servable. Saying "done" would promise an actor that is dead,
879 // and saying "failed" would lose a name that IS held.
880 return seeOther(res, '/settings?m=' + encodeURIComponent(r.pending
881 ? 'the name ' + (r.acct || name) + ' is yours, but the bridge could not start serving it yet'
882 : 'the bridge carries you as ' + (r.acct || name)))
883 } catch (err) {
884 // The daemon's message verbatim — it knows whether the name was taken, an invite was needed,
885 // or the bridge simply did not answer.
886 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
887 }
888 }
889
890 if (req.method === 'GET' && path === '/bridge/release') {
891 const reach = await client.call('reach').catch(() => null)
892 if (!reach || !reach.fediverse) return seeOther(res, '/settings')
893 return send(res, pages.bridgeRelease(reach.fediverse, url.searchParams.get('m')))
894 }
895
896 if (req.method === 'POST' && path === '/bridge/release') {
897 const reach = await client.call('reach').catch(() => null)
898 if (!reach || !reach.fediverse) return seeOther(res, '/settings')
899 // `name@host` — the bridge knows it by the name alone, and the daemon remembers which bridge
900 // that was, so nobody has to paste a key again to perform the one irreversible act here.
901 const name = String(reach.fediverse).split('@')[0]
902 try {
903 await client.call('bridge-release', { acct: reach.fediverse, name })
904 } catch (err) {
905 return seeOther(res, '/bridge/release?m=' + encodeURIComponent(err.message))
906 }
907 await client.call('set-reach', { fediverse: null }).catch(() => {})
908 return seeOther(res, '/settings?m=' + encodeURIComponent('that name has been given up'))
909 }
910
861911 // ── Flow 1 — the atproto identity ──────────────────────────────────────────────────────────────
862912 //
863913 // ⛔ THREE ROUTES FOR THREE COSTS. Minting reaches nothing and is free; showing the phrase is the
test/routes.test.js +92 -0
……@@ -1496,6 +1496,98 @@ test('⚠ designate does nothing when the node has no identity', async (t) => {
14961496 t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was designated')
14971497})
14981498
1499// ── Flow 2 — a name on a bridge ──────────────────────────────────────────────────────────────────
1500//
1501// ⛔ WHAT WAS HERE WAS A BOX ASKING YOU TO TYPE THE ANSWER. Recording `name@bridge` performed
1502// nothing, and the two values a bridge requires — an identity root and a feed key — appear nowhere in
1503// this UI, so from a handset it could not be obtained at all.
1504test('★ settings offers to ask a bridge for a name', async (t) => {
1505 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1506 answers.profile = { name: 'x' }
1507 answers.profiles = {}
1508 answers.namespace = null
1509 answers.reach = { networks: [], credential: null, fediverse: null }
1510 const r = await request('GET', '/settings', { headers: { cookie } })
1511 t.is(r.status, 200)
1512 t.ok(r.body.includes('/bridge/claim'), 'a form that asks, rather than a box to transcribe into')
1513 t.ok(/Bridge key/.test(r.body), 'addressed by key, not hostname')
1514})
1515
1516test('★★ POST /bridge/claim asks the daemon, then records what the bridge agreed', async (t) => {
1517 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1518 answers['bridge-claim'] = { username: 'alice', acct: 'alice@ap.test', generation: 1, pending: false }
1519 answers['set-reach'] = {}
1520 calls.length = 0
1521 const r = await request('POST', '/bridge/claim', {
1522 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1523 body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
1524 })
1525 t.is(r.status, 303)
1526 const claim = calls.find((c) => c.cmd === 'bridge-claim')
1527 t.ok(claim, 'the daemon was asked')
1528 t.is(claim.args.name, 'alice')
1529 t.is(claim.args.bridge, 'b'.repeat(64), 'by key')
1530
1531 // ⛔ Recorded only AFTER the bridge agreed. Writing it first is exactly what the old text box did.
1532 const set = calls.find((c) => c.cmd === 'set-reach')
1533 t.is(set.args.fediverse, 'alice@ap.test')
1534 t.ok(calls.indexOf(claim) < calls.indexOf(set), 'and in that order')
1535})
1536
1537test('⛔ a refusal from the bridge reaches the person verbatim, and records nothing', async (t) => {
1538 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1539 answers['bridge-claim'] = new Error('taken')
1540 calls.length = 0
1541 const r = await request('POST', '/bridge/claim', {
1542 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1543 body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
1544 })
1545 t.is(r.status, 303)
1546 t.ok(decodeURIComponent(r.headers.location || '').includes('taken'), r.headers.location)
1547 t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was recorded')
1548})
1549
1550// ⚠ 202: claimed but not yet servable. "Done" would promise a dead actor; "failed" would lose a name
1551// that IS held.
1552test('⚠ a name claimed but not yet served says both halves', async (t) => {
1553 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1554 answers['bridge-claim'] = { acct: 'alice@ap.test', pending: 'swarm unreachable' }
1555 answers['set-reach'] = {}
1556 const r = await request('POST', '/bridge/claim', {
1557 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1558 body: 'bridge=' + 'b'.repeat(64) + '&name=alice'
1559 })
1560 const m = decodeURIComponent(r.headers.location || '')
1561 t.ok(/is yours/.test(m) && /could not start serving/.test(m), m)
1562})
1563
1564// ⛔ Releasing BURNS the name — the next holder gets a new identity on purpose — so it gets its own
1565// screen, and a GET must never perform it.
1566test('⛔ GET /bridge/release confirms and releases NOTHING', async (t) => {
1567 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1568 answers.reach = { networks: [], credential: null, fediverse: 'alice@ap.test' }
1569 calls.length = 0
1570 const r = await request('GET', '/bridge/release', { headers: { cookie } })
1571 t.is(r.status, 200)
1572 t.ok(/permanent/i.test(r.body), 'it says what this costs')
1573 t.absent(calls.find((c) => c.cmd === 'bridge-release'), 'and a GET releases nothing')
1574})
1575
1576test('★ POST /bridge/release uses the remembered bridge, and clears the name', async (t) => {
1577 answers.whoami = { feed: 'ab', device: 'cd', linked: true }
1578 answers.reach = { networks: [], credential: null, fediverse: 'alice@ap.test' }
1579 answers['bridge-release'] = { released: 'alice' }
1580 answers['set-reach'] = {}
1581 calls.length = 0
1582 const r = await request('POST', '/bridge/release', {
1583 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, body: ''
1584 })
1585 t.is(r.status, 303)
1586 const rel = calls.find((c) => c.cmd === 'bridge-release')
1587 t.is(rel.args.acct, 'alice@ap.test', 'the daemon looks the key up itself — no re-pasting')
1588 t.is(calls.find((c) => c.cmd === 'set-reach').args.fediverse, null, 'and the name is cleared')
1589})
1590
14991591test('shut down', async (t) => {
15001592 await new Promise((r) => server.close(r))
15011593 await new Promise((r) => daemon.close(r))