| … | … | @@ -1579,13 +1579,14 @@ export function reachSettings (reach, did = null) { |
| 1579 | 1579 | // ⚠ It is deliberately NOT beside the DID field. §5.1's `credential` is the designated ATPROTO one |
| 1580 | 1580 | // and the two networks are not symmetric: on bsky you hold the key, on the fediverse a bridge |
| 1581 | 1581 | // 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 | : '' | |
| 1589 | 1590 | |
| 1590 | 1591 | return '<h2>Publishing</h2>' + |
| 1591 | 1592 | (on.size === 0 |
| … | … | @@ -1679,7 +1680,7 @@ export function settings (profile, message, handle = null, namespace = null, tok |
| 1679 | 1680 | const shownBanner = p.banner |
| 1680 | 1681 | ? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>' |
| 1681 | 1682 | : '' |
| 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) + | |
| 1683 | 1684 | '<h1>Settings</h1>' + shown + |
| 1684 | 1685 | '<form method="post" action="/settings" enctype="multipart/form-data">' + |
| 1685 | 1686 | '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') + |
| … | … | @@ -2085,3 +2086,62 @@ export function didPublish (did, message = null) { |
| 2085 | 2086 | '<form method="post" action="/did/publish"><button>publish</button></form>' + |
| 2086 | 2087 | '<p><a href="/settings">not now</a></p>') |
| 2087 | 2088 | } |
| 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. | |
| 2105 | export 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 | */ | |
| 2137 | export 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 | } |
| … | … | @@ -858,6 +858,56 @@ export async function route (req, res, ctx) { |
| 858 | 858 | return seeOther(res, '/settings') |
| 859 | 859 | } |
| 860 | 860 | |
| 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 | ||
| 861 | 911 | // ── Flow 1 — the atproto identity ────────────────────────────────────────────────────────────── |
| 862 | 912 | // |
| 863 | 913 | // ⛔ THREE ROUTES FOR THREE COSTS. Minting reaches nothing and is free; showing the phrase is the |
| … | … | @@ -1496,6 +1496,98 @@ test('⚠ designate does nothing when the node has no identity', async (t) => { |
| 1496 | 1496 | t.absent(calls.find((c) => c.cmd === 'set-reach'), 'nothing was designated') |
| 1497 | 1497 | }) |
| 1498 | 1498 | |
| 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. | |
| 1504 | test('★ 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 | ||
| 1516 | test('★★ 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 | ||
| 1537 | test('⛔ 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. | |
| 1552 | test('⚠ 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. | |
| 1566 | test('⛔ 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 | ||
| 1576 | test('★ 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 | ||
| 1499 | 1591 | test('shut down', async (t) => { |
| 1500 | 1592 | await new Promise((r) => server.close(r)) |
| 1501 | 1593 | await new Promise((r) => daemon.close(r)) |