OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / f8ff356

settings is one section per network, in dependency order
The page had "Publishing", "Bluesky identity" and "Bridge" — where "Bridge" was the FEDIVERSE one and Bluesky's bridge was a link inside a different card. Two sections asked for "a bridge key" and only one named its network. Somebody pasted the Bluesky key into the fediverse form, got an error, retried, and got another. Now: Bluesky and Mastodon each own everything about themselves, numbered in the order they depend on each other — identity, then who carries it, then whether anything is sent. Each step says what the one below it still needs, because the order was the invisible part: a switch does nothing without an identity, and an identity reaches nobody without a bridge. ⛔ One form per network forces the toggle to be per-network. The old single form read both checkboxes at once, so splitting it naively would have meant saving one network silently switching the other off — an unticked box sends nothing, and "absent means off" is only safe when the form carries them all. POST /reach now takes one network and merges, and designation is its own submit that leaves the networks alone. ⚠ "Use a different Bluesky identity" is kept rather than dropped. Tidying a page is not a reason to remove the only way to say something. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f8ff356d34dfda13380aa819ed68bae3a9bc6b02
by plankatron <fleeky@gmail.com> · 2026-09-11T19:57:48.000Z

Changed files (3) +206 -232

lib/pages.js +119 -196
……@@ -1530,77 +1530,6 @@ 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, did = null) {
1534 if (!reach) return ''
1535 const on = new Set(Array.isArray(reach.networks) ? reach.networks : [])
1536 // ⛔ ONE FIELD PER NETWORK. `body()` in the router keeps the last value for a repeated name, so
1537 // two checkboxes sharing one name would collapse into a single answer and silently drop whichever
1538 // the person ticked first.
1539 const box = (name, label, why) =>
1540 '<p><label><input type="checkbox" name="reach-' + esc(name) + '" value="on"' +
1541 (on.has(name) ? ' checked' : '') + '> ' + esc(label) + '</label>' +
1542 '<br><span class="who">' + esc(why) + '</span></p>'
1543
1544 // ⛔ identity.md §5.1 — with no designated credential the projection is WITHHELD rather than
1545 // published under a guess: "Several without a designation → refuse to project. Silently picking
1546 // one would publish under an identity the author did not choose." The page has to say so, or an
1547 // enabled network looks like it works.
1548 //
1549 // ⛔ AND IT HAS TO BE SETTABLE HERE. This said "Link one on the machine running the daemon" and
1550 // that mechanism did not exist: `setReach` accepts a credential, this rendered one, and NOTHING
1551 // anywhere passed one — no route, no CLI verb. An instruction pointing at a door nobody built is
1552 // worse than no instruction, because it reads like operator error. Same shape as `ota.js` being
1553 // copied and never imported.
1554 //
1555 // ⚠ It is the ATPROTO credential specifically (§5.1: "designated: 'atproto'"), which is why it
1556 // says DID rather than "identifier". The fediverse equivalent is not a credential at all — §6
1557 // makes a bridge a RESOURCE you hold a grant on, obtained by registering with one.
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>' +
1564 '<input type="text" name="credential" value="' + esc(reach.credential || '') + '" ' +
1565 'placeholder="did:plc:…" autocomplete="off"></label></p>' +
1566 (reach.credential
1567 ? '<p class="who">Publishing to Bluesky as <code>' + esc(reach.credential) + '</code></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>')
1573
1574 // ⛔ A NAME YOU HOLD, RECORDED — not a claim performed here. Asking a bridge for a name means
1575 // reaching for a projection, which `test/boundary.test.js` forbids this package outright ("the
1576 // surface holds a socket and nothing else"). Recording the answer is just data, and the daemon
1577 // already stores it beside `networks`.
1578 //
1579 // ⚠ It is deliberately NOT beside the DID field. §5.1's `credential` is the designated ATPROTO one
1580 // and the two networks are not symmetric: on bsky you hold the key, on the fediverse a bridge
1581 // holds it and you hold a grant. One input each, labelled for what it actually is.
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 : ''
1590
1591 return '<h2>Publishing</h2>' +
1592 (on.size === 0
1593 ? '<p class="who">Nothing you write leaves favela.</p>'
1594 : '<p class="who">Posts you write in a public room are also sent to the networks ticked below.</p>') +
1595 '<form method="post" action="/reach">' +
1596 box('bsky', 'Bluesky', 'Plain text only, up to 300 characters. Longer posts are withheld, not shortened.') +
1597 box('fediverse', 'Mastodon and the fediverse', 'Keeps headings, lists and links. No length limit.') +
1598 cred + fedi +
1599 '<p class="who">⛔ A post sent to either network cannot be recalled from it. Turning a network ' +
1600 'off stops future posts; it does not retract the ones already sent.</p>' +
1601 '<button>save</button></form>'
1602}
1603
16041533
16051534// ── app updates ──────────────────────────────────────────────────────────────────────────────────
16061535//
……@@ -1680,7 +1609,7 @@ export function settings (profile, message, handle = null, namespace = null, tok
16801609 const shownBanner = p.banner
16811610 ? '<p><img class="banner-lg" src="/avatar/' + esc(p.banner) + '" alt="your banner"></p>'
16821611 : ''
1683 return layout('settings', note + updateSettings(update) + reachSettings(reach, did) + didSettings(did, reach) + bridgeSettings(reach) +
1612 return layout('settings', note + updateSettings(update) + blueskySection(did, reach) + fediverseSection(reach) +
16841613 '<h1>Settings</h1>' + shown +
16851614 '<form method="post" action="/settings" enctype="multipart/form-data">' +
16861615 '<p><label>Display name<br><input type="text" name="name" value="' + esc(p.name || '') +
……@@ -1943,83 +1872,6 @@ export function passphraseFields (name = 'passphrase', { required = false, hint
19431872 (hint ? '<p class="who">' + esc(hint) + '</p>' : '')
19441873}
19451874
1946// ── Flow 1 — the atproto identity ────────────────────────────────────────────────────────────────
1947//
1948// ⛔ THREE STATES, AND IT NAMES THE ONE IT IS IN. What stood here was a text box you pasted a DID
1949// into, which recorded a string and performed nothing — hyper-social #69 §10.7 filed it as "wired a
1950// field nothing reads". Minting exists, publishing exists; neither had a route to it.
1951//
1952// ⛔ AND IT REPORTS STATE, NOT A VERDICT (#69 §3, earned four times): the daemon says what is on
1953// disk — a DID, a sealed vault, a submission — and this decides what to show. A page that printed a
1954// conclusion could not be used to discover the conclusion was wrong.
1955//
1956// ⚠ ABSENT, not empty, when the daemon cannot answer. A surface pointed at a plain favela-peer gets
1957// no `did-status`, and rendering "you have no identity" there would be a claim this page cannot
1958// support — the same tolerance `reachSettings` already applies to a daemon with no `reach`.
1959export function didSettings (did, reach = null) {
1960 if (!did) return ''
1961
1962 if (!did.did) {
1963 return '<h2>Bluesky identity</h2>' +
1964 '<p class="who">You do not have one. Creating it is free and reaches no outside service — ' +
1965 'publishing to the Bluesky network is a separate step you can take later, or never.</p>' +
1966 '<p><a href="/did/create">Create an identity</a></p>'
1967 }
1968
1969 const vault = did.sealed
1970 ? '<p class="who">Your recovery phrase is sealed in a vault on this device.</p>'
1971 : '<p class="warn">No recovery vault is on this device. If you did not write the phrase down, ' +
1972 'this identity cannot be recovered.</p>'
1973
1974 // ⛔ DESIGNATION IS OFFERED, NEVER SILENT. identity.md §5.1: a user may hold several DIDs and the
1975 // signer needs exactly one at sign time, so "several without a designation → refuse to project.
1976 // Silently picking one would publish under an identity the author did not choose." Minting one
1977 // does not elect it; this is the election, and it is one button with a name on it.
1978 //
1979 // ⚠ Absent when `reach` could not be read — the same tolerance the rest of settings applies — and
1980 // absent when this identity is ALREADY the designated one, because an action that changes nothing
1981 // reads as an action that did not work.
1982 const designated = reach && reach.credential === did.did
1983 const designate = !reach
1984 ? ''
1985 : designated
1986 ? '<p class="who">Posts you send to Bluesky are published under this identity.</p>'
1987 : '<form method="post" action="/did/designate">' +
1988 '<button>publish under this identity</button></form>' +
1989 '<p class="who">Nothing is sent to Bluesky until you also switch the network on above.</p>'
1990
1991 if (did.submitted) {
1992 // ⛔ PUBLISHED IS NOT REACHABLE, and conflating them is why this looked finished when it was not.
1993 // A mint is P2P-only: the genesis carries `alsoKnownAs: []` and `services: {}`, so a published
1994 // DID has no server for Bluesky to fetch a repo from and resolves for nobody. A bridge grants the
1995 // handle and the endpoint, and the peer puts them on-chain.
1996 const carried = did.handle
1997 ? '<p>Reachable as <code>' + esc(did.handle) + '</code>' +
1998 (did.pdsUrl ? ' <span class="who">via ' + esc(did.pdsUrl) + '</span>' : '') + '</p>'
1999 : '<p class="warn">Published, but nothing can resolve it yet: this identity names no handle and ' +
2000 'no server. Ask a bridge to carry it.</p>' +
2001 '<p><a href="/did/carry">Get carried by a bridge</a></p>'
2002 return '<h2>Bluesky identity</h2>' +
2003 '<p>Published as <code>' + esc(did.did) + '</code></p>' + carried + vault + designate
2004 }
2005
2006 return '<h2>Bluesky identity</h2>' +
2007 '<p><code>' + esc(did.did) + '</code></p>' +
2008 '<p class="who">This works between favela peers. The Bluesky network cannot see it yet.</p>' +
2009 vault +
2010 (did.publishable
2011 ? '<p><a href="/did/publish">Publish to the Bluesky network</a></p>'
2012 : '<p class="warn">The genesis operation for this identity is not on this device, so it cannot ' +
2013 'be published from here. It can be re-derived from your recovery phrase.</p>') +
2014 designate
2015}
2016
2017/**
2018 * The confirmation in front of minting.
2019 *
2020 * ⛔ A SEPARATE SCREEN, because the next one shows a secret exactly once and somebody who arrived by
2021 * mis-tapping a button is not ready to write down 24 words.
2022 */
20231875export function didCreate (message = null) {
20241876 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
20251877 return layout('create an identity', warn +
……@@ -2097,53 +1949,6 @@ export function didPublish (did, message = null) {
20971949 '<p><a href="/settings">not now</a></p>')
20981950}
20991951
2100// ── Flow 2 — a name on a bridge ──────────────────────────────────────────────────────────────────
2101//
2102// ⛔ WHAT WAS HERE WAS A BOX THAT ASKED YOU TO TYPE THE ANSWER. Recording `name@bridge` performed
2103// nothing, and the two values a bridge actually requires — an identity root and a feed key — appear
2104// nowhere in this UI, so from a handset there was no way to obtain one at all. The only route was a
2105// hand-written HTTP call using values read off a desktop CLI.
2106//
2107// ⛔ THE PEER ASKS, NOT THIS PAGE. `bridge-claim` fetches a challenge, signs bytes that bind this
2108// name and this feed, and sends both; the daemon holds the credential and this page never sees one.
2109// It is also deliberately generic — the daemon is not told what a fediverse is, so the same ceremony
2110// serves any bridge.
2111//
2112// ⛔ BY KEY, NOT HOSTNAME. A topic derived from a name is squattable, and while a squatter cannot
2113// steal a claim — the root and feed key are public and the proof is bound to a single-use nonce — it
2114// CAN answer "registered!" and leave this node recording a name that does not exist.
2115export function bridgeSettings (reach) {
2116 if (!reach) return ''
2117 const held = reach.fediverse
2118
2119 if (held) {
2120 return '<h2>Bridge</h2>' +
2121 '<p>Your feed is carried as <code>' + esc(held) + '</code></p>' +
2122 '<p class="who">A bridge holds the signing key for that network and serves your feed under this ' +
2123 'name. You hold the name; it holds the delivery.</p>' +
2124 '<p><a href="/bridge/release">Give up this name</a></p>'
2125 }
2126
2127 return '<h2>Bridge</h2>' +
2128 '<p class="who">A bridge carries your posts onto a network favela does not speak. Ask one for a ' +
2129 'name and it will hold your feed under it.</p>' +
2130 '<form method="post" action="/bridge/claim">' +
2131 '<p><label>Bridge key<br><input type="text" name="bridge" placeholder="64 hex characters" ' +
2132 'autocomplete="off"></label></p>' +
2133 '<p class="who">The bridge prints this when it starts. A key rather than an address, so the ' +
2134 'answer can only come from the bridge you meant.</p>' +
2135 '<p><label>Name you want<br><input type="text" name="name" placeholder="alice" ' +
2136 'autocomplete="off"></label></p>' +
2137 '<p><label>Invite (only if the bridge is closed)<br><input type="text" name="invite" ' +
2138 'placeholder="leave empty on an open bridge" autocomplete="off"></label></p>' +
2139 '<button>ask for this name</button></form>'
2140}
2141
2142/**
2143 * ⛔ ITS OWN SCREEN, because a release cannot be undone. The bridge burns the name: the next holder
2144 * gets a NEW identity on purpose, so the followers of the old one do not transfer — which is what
2145 * stops name reuse becoming impersonation, and also means there is no way back for you.
2146 */
21471952export function bridgeRelease (name, message = null) {
21481953 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
21491954 return layout('give up this name', warn +
……@@ -2191,3 +1996,121 @@ export function didCarry (did, message = null) {
21911996 '<button>ask, and publish</button></form>' +
21921997 '<p><a href="/settings">cancel</a></p>')
21931998}
1999
2000// ── one section per network ──────────────────────────────────────────────────────────────────────
2001//
2002// ⛔ SPLIT BY NETWORK, NOT BY MECHANISM. This page used to have "Publishing" (two checkboxes and a
2003// DID box), "Bluesky identity", and "Bridge" — where "Bridge" was the FEDIVERSE one and Bluesky's
2004// bridge was a link inside a different card. Two sections offered "a bridge key" and only one named
2005// its network, so the obvious thing to do was paste the Bluesky key into the fediverse form. Somebody
2006// did exactly that, got an error, retried, and got a second error about channels.
2007//
2008// ⛔ AND IN DEPENDENCY ORDER, because the order is the thing that was invisible. A switch does
2009// nothing without an identity; an identity reaches nobody without a bridge. Each section now reads
2010// top to bottom as the sequence it actually is, and each step says what the one below it still needs.
2011//
2012// ⚠ ONE FORM PER NETWORK, which forces the toggle to be per-network too. The old single form read
2013// both checkboxes at once, so two forms would have meant saving one network silently switching the
2014// other off — `POST /reach` now takes one network and merges.
2015
2016const switchFor = (network, label, on, blocked) =>
2017 '<form method="post" action="/reach">' +
2018 '<input type="hidden" name="network" value="' + esc(network) + '">' +
2019 '<p><label><input type="checkbox" name="on" value="on"' + (on ? ' checked' : '') + '> ' + esc(label) +
2020 '</label></p>' +
2021 (blocked
2022 ? '<p class="warn">' + esc(blocked) + '</p>'
2023 : '') +
2024 '<button>save</button></form>'
2025
2026/**
2027 * Bluesky, end to end: who you are, who carries you, and whether anything is sent.
2028 *
2029 * ⚠ Absent when the daemon cannot answer — a surface talking to a plain favela-peer has no atproto
2030 * verbs, and offering what cannot be done is worse than offering nothing.
2031 */
2032export function blueskySection (did, reach) {
2033 if (!did) return ''
2034 const on = reach && Array.isArray(reach.networks) && reach.networks.includes('bsky')
2035
2036 if (!did.did) {
2037 return '<h2>Bluesky</h2>' +
2038 '<p class="who">Nothing is sent to Bluesky yet. It needs an identity, and a bridge to carry it.</p>' +
2039 '<p><a href="/did/create">Create a Bluesky identity</a></p>' +
2040 '<p class="who">Free, and it reaches no outside service until you publish it.</p>'
2041 }
2042
2043 // ⛔ THE THREE STEPS, always all three, always in this order. A page that showed only the step you
2044 // were on could not tell you why the one above it had not taken effect.
2045 const identity = '<p><strong>1. Identity</strong><br><code>' + esc(did.did) + '</code></p>' +
2046 (did.sealed
2047 ? '<p class="who">Your recovery phrase is sealed in a vault on this device.</p>'
2048 : '<p class="warn">No recovery vault on this device. If you did not write the phrase down, this ' +
2049 'identity cannot be recovered.</p>')
2050
2051 const carried = did.handle
2052 ? '<p><strong>2. Carried by</strong><br><code>' + esc(did.handle) + '</code>' +
2053 (did.pdsUrl ? ' <span class="who">via ' + esc(did.pdsUrl) + '</span>' : '') + '</p>'
2054 : '<p><strong>2. Carried by</strong> — nobody yet</p>' +
2055 '<p class="who">Until a bridge carries it, this identity names no handle and no server, so ' +
2056 'Bluesky cannot resolve it however it was published.</p>' +
2057 '<p><a href="/did/carry">Get carried by a bridge</a></p>'
2058
2059 const designated = reach && reach.credential === did.did
2060 const designate = !reach
2061 ? ''
2062 : designated
2063 ? '<p class="who">Posts go out under this identity.</p>'
2064 : '<form method="post" action="/did/designate"><button>publish under this identity</button></form>'
2065
2066 // ⚠ KEPT, and secondary. Designating a DID held somewhere else is a real thing to want, and the
2067 // button above covers the common case — but removing the field entirely would take away the only
2068 // way to say it. It names its own network now, which the old shared box did not.
2069 const other = '<details><summary class="who">Use a different Bluesky identity</summary>' +
2070 '<form method="post" action="/reach">' +
2071 '<p><label>DID to publish under<br><input type="text" name="credential" ' +
2072 'value="' + esc((reach && reach.credential) || '') + '" placeholder="did:plc:…" autocomplete="off">' +
2073 '</label></p>' +
2074 '<p class="who">Leave empty to publish under the identity above. Nothing is sent to Bluesky until ' +
2075 'one is chosen.</p><button>save</button></form></details>'
2076
2077 return '<h2>Bluesky</h2>' + identity + carried +
2078 '<p><strong>3. Send posts there</strong></p>' + designate + other +
2079 switchFor('bsky', 'Send my public posts to Bluesky', on,
2080 !did.handle ? 'No bridge carries this identity yet, so nothing will be sent even with this on.' : null) +
2081 '<p class="who">Plain text only, up to 300 characters — longer posts are withheld, not shortened. ' +
2082 'A post sent there cannot be recalled.</p>'
2083}
2084
2085/**
2086 * Mastodon and the fediverse, end to end: the name a bridge granted, and whether anything is sent.
2087 */
2088export function fediverseSection (reach) {
2089 if (!reach) return ''
2090 const on = Array.isArray(reach.networks) && reach.networks.includes('fediverse')
2091 const held = reach.fediverse
2092
2093 const name = held
2094 ? '<p><strong>1. Your name</strong><br><code>' + esc(held) + '</code></p>' +
2095 '<p class="who">A bridge holds the signing key for that network and serves your feed under this ' +
2096 'name. You hold the name; it holds the delivery.</p>' +
2097 '<p><a href="/bridge/release">Give up this name</a></p>'
2098 : '<p><strong>1. Your name</strong> — none yet</p>' +
2099 '<form method="post" action="/bridge/claim">' +
2100 '<p><label>Fediverse bridge key<br><input type="text" name="bridge" placeholder="64 hex characters" ' +
2101 'autocomplete="off"></label></p>' +
2102 '<p class="who">⚠ The key of a FEDIVERSE bridge. A Bluesky bridge has its own, and they are not ' +
2103 'interchangeable — pasting one into the other fails in a way that does not say so.</p>' +
2104 '<p><label>Name you want<br><input type="text" name="name" placeholder="alice" ' +
2105 'autocomplete="off"></label></p>' +
2106 '<p><label>Invite (only if that bridge is closed)<br><input type="text" name="invite" ' +
2107 'placeholder="leave empty on an open bridge" autocomplete="off"></label></p>' +
2108 '<button>ask for this name</button></form>'
2109
2110 return '<h2>Mastodon and the fediverse</h2>' + name +
2111 '<p><strong>2. Send posts there</strong></p>' +
2112 switchFor('fediverse', 'Send my public posts to the fediverse', on,
2113 !held ? 'No bridge carries you yet, so nothing will be sent even with this on.' : null) +
2114 '<p class="who">Keeps headings, lists and links. No length limit. A post sent there cannot be ' +
2115 'recalled.</p>'
2116}
lib/router.js +33 -15
……@@ -967,6 +967,9 @@ export async function route (req, res, ctx) {
967967 const st = await client.call('did-status').catch(() => null)
968968 if (!st || !st.did) return seeOther(res, '/settings')
969969 if (st.handle) return seeOther(res, '/settings?m=' + encodeURIComponent('a bridge already carries this identity'))
970 // ⚠ Not gated on `submitted`: that flag comes from a local record which only exists for identities
971 // minted after it did. Whether the DID is really in the directory is the directory's question, and
972 // the adopt step asks it.
970973 return send(res, pages.didCarry(st.did, url.searchParams.get('m')))
971974 }
972975
……@@ -1026,26 +1029,41 @@ export async function route (req, res, ctx) {
10261029 */
10271030 if (req.method === 'POST' && path === '/reach') {
10281031 const form = await body(req)
1029 // ⚠ One field per network — see reachSettings(). An unchecked box sends nothing at all, so
1030 // absence is the correct reading of "off" here.
1031 const networks = ['bsky', 'fediverse'].filter((n) => form['reach-' + n])
1032 // ⛔ SENT, because it never was. The field has been rendered and accepted by the daemon the
1033 // whole time with nothing in between — so the page told people to designate an identity
1034 // somewhere else, and there was no somewhere else.
1035 //
1036 // ⚠ An empty box CLEARS it, deliberately: `setReach` treats a falsy credential as null, and
1037 // withholding is the fail-closed state. Leaving it unsendable would make "stop publishing under
1038 // this identity" impossible from the only surface that can say it.
1039 const credential = String(form.credential || '').trim() || null
1040 // The name a bridge grant produced. Recorded, never claimed here — see reachSettings().
1041 const fediverse = String(form.fediverse || '').trim() || null
1032 // ⛔ ONE NETWORK PER SUBMIT, MERGED. This read every checkbox from a single form, so with one
1033 // section per network a save would have silently switched the OTHER network off — an unticked box
1034 // sends nothing, and "absent means off" is only safe when the form carries them all.
1035 // ⛔ DESIGNATION IS ITS OWN SUBMIT. `setReach` merges, so a form naming only `credential` leaves
1036 // the networks alone and vice versa — which is what lets one section hold both without either
1037 // clobbering the other.
1038 if (Object.prototype.hasOwnProperty.call(form, 'credential')) {
1039 const credential = String(form.credential || '').trim() || null
1040 try {
1041 await client.call('set-reach', { credential })
1042 } catch (err) {
1043 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
1044 }
1045 return seeOther(res, '/settings?m=' + encodeURIComponent(
1046 credential ? 'posts go out under ' + credential : 'no identity is designated, so nothing is published to Bluesky'))
1047 }
1048
1049 const network = String(form.network || '').trim()
1050 if (network !== 'bsky' && network !== 'fediverse') return seeOther(res, '/settings')
1051 const on = !!form.on
1052
1053 // ⚠ Read first: a merge needs the other network's current answer, and this page must not be the
1054 // thing that remembers it.
1055 const reach = await client.call('reach').catch(() => null)
1056 const current = new Set(reach && Array.isArray(reach.networks) ? reach.networks : [])
1057 if (on) current.add(network); else current.delete(network)
1058
1059 const named = network === 'bsky' ? 'Bluesky' : 'the fediverse'
10421060 try {
1043 await client.call('set-reach', { networks, credential, fediverse })
1061 await client.call('set-reach', { networks: [...current] })
10441062 } catch (err) {
10451063 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
10461064 }
10471065 return seeOther(res, '/settings?m=' + encodeURIComponent(
1048 networks.length ? 'Publishing to ' + networks.join(' and ') + '.' : 'Publishing nowhere but favela.'))
1066 on ? 'posts will be sent to ' + named : 'posts will no longer be sent to ' + named))
10491067 }
10501068
10511069 if (req.method === 'POST' && path === '/settings') {
test/routes.test.js +54 -21
……@@ -1164,29 +1164,39 @@ test('★★ an empty timeline asks WHO you follow, and a full one does not', as
11641164 t.is(called('following').length, 0, 'not asked when there are posts')
11651165})
11661166
1167test('★★ the publishing toggles reach the daemon, one field per network', async (t) => {
1167// ⛔ ONE NETWORK PER SUBMIT, MERGED WITH THE OTHER. The page now has a section per network, so a
1168// single form covering both would mean saving one silently switched the other off — an unticked box
1169// sends nothing, and "absent means off" is only safe when the form carries every network.
1170test('★★ a toggle changes ONE network and leaves the other alone', async (t) => {
11681171 reset()
1169 answers = { 'set-reach': { networks: ['bsky'], credential: null } }
1172 answers = { reach: { networks: ['fediverse'], credential: null }, 'set-reach': {} }
11701173 const r = await request('POST', '/reach', {
1171 body: 'reach-bsky=on',
1174 body: 'network=bsky&on=on',
11721175 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
11731176 })
11741177 t.is(r.status, 303)
1175 t.alike(called('set-reach')[0].args.networks, ['bsky'])
1178 t.alike(called('set-reach')[0].args.networks.sort(), ['bsky', 'fediverse'],
1179 'the fediverse stayed on — it was never part of this form')
1180})
11761181
1177 // ⛔ BOTH, not the last one. The form parser keeps the last value for a repeated field name, so
1178 // sharing one name across two checkboxes would silently drop whichever was ticked first.
1182test('⛔ turning one off does not turn the other off with it', async (t) => {
11791183 reset()
1184 answers = { reach: { networks: ['bsky', 'fediverse'], credential: null }, 'set-reach': {} }
11801185 await request('POST', '/reach', {
1181 body: 'reach-bsky=on&reach-fediverse=on',
1186 body: 'network=bsky',
11821187 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
11831188 })
1184 t.alike(called('set-reach')[0].args.networks, ['bsky', 'fediverse'])
1189 t.alike(called('set-reach')[0].args.networks, ['fediverse'], 'only bsky was removed')
1190})
11851191
1186 // ⛔ An unchecked box sends nothing, so nothing means OFF — not "unchanged".
1192test('⛔ a submit naming no known network changes nothing', async (t) => {
11871193 reset()
1188 await request('POST', '/reach', { body: 'submitted=1', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' } })
1189 t.alike(called('set-reach')[0].args.networks, [])
1194 answers = { reach: { networks: ['bsky'] }, 'set-reach': {} }
1195 await request('POST', '/reach', {
1196 body: 'network=nonsense&on=on',
1197 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1198 })
1199 t.is(called('set-reach').length, 0, 'nothing was written')
11901200})
11911201
11921202test('⛔ a daemon that REFUSES the change is reported, not routed around', async (t) => {
……@@ -1195,24 +1205,45 @@ test('⛔ a daemon that REFUSES the change is reported, not routed around', asyn
11951205 reset()
11961206 answers = { 'set-reach': new Error('this device is not admitted to its own feed') }
11971207 const r = await request('POST', '/reach', {
1198 body: 'reach-bsky=on', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1208 body: 'network=bsky&on=on', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
11991209 })
12001210 t.is(r.status, 303)
12011211 t.ok(/not%20admitted|not admitted/.test(String(r.headers.location)), 'the reason travels: ' + r.headers.location)
12021212})
12031213
1204test('⚠ settings renders publishing state, and says OFF plainly', async (t) => {
1214// ⛔ ONE SECTION PER NETWORK, NAMED. The page had "Publishing", "Bluesky identity" and "Bridge" —
1215// where "Bridge" was the FEDIVERSE one and Bluesky's bridge was a link inside another card. Two
1216// sections asked for "a bridge key" and only one named its network, so the obvious move was to paste
1217// the Bluesky key into the fediverse form. Somebody did, got an error, retried, got another.
1218test('⚠ each network gets its own named section, in dependency order', async (t) => {
12051219 reset()
12061220 answers = {
12071221 whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null,
1208 reach: { networks: [], credential: null }
1222 reach: { networks: [], credential: null, fediverse: null },
1223 'did-status': { did: 'did:plc:abc', sealed: true, submitted: false, handle: null }
12091224 }
12101225 const r = await request('GET', '/settings', { headers: { cookie } })
1211 t.ok(r.body.includes('Publishing'), 'the section is there')
1212 t.ok(r.body.includes('Nothing you write leaves favela'), 'and off is stated, not implied by an empty box')
1226 t.ok(/<h2>Bluesky<\/h2>/.test(r.body), 'Bluesky is its own section')
1227 t.ok(/<h2>Mastodon and the fediverse<\/h2>/.test(r.body), 'and so is the fediverse')
1228 t.absent(/<h2>Bridge<\/h2>/.test(r.body), 'no section called just "Bridge" — that was the ambiguity')
1229 t.ok(r.body.includes('Fediverse bridge key'), 'the bridge field names WHICH network it belongs to')
12131230 t.ok(r.body.includes('cannot be recalled'), 'and the cost is named beside the switch')
12141231})
12151232
1233// ⛔ THE ORDER WAS THE INVISIBLE PART. A switch does nothing without an identity; an identity reaches
1234// nobody without a bridge. Each step now says what the one below it still needs.
1235test('⚠ a switch with nothing behind it says so rather than looking enabled', async (t) => {
1236 reset()
1237 answers = {
1238 whoami: { feed: 'ab'.repeat(32) }, profiles: {}, profile: {}, namespace: null,
1239 reach: { networks: ['bsky', 'fediverse'], credential: null, fediverse: null },
1240 'did-status': { did: 'did:plc:abc', sealed: true, submitted: false, handle: null }
1241 }
1242 const r = await request('GET', '/settings', { headers: { cookie } })
1243 t.ok(/No bridge carries this identity yet/.test(r.body), 'Bluesky says why nothing is sent')
1244 t.ok(/No bridge carries you yet/.test(r.body), 'and so does the fediverse')
1245})
1246
12161247// ── the identity gate ────────────────────────────────────────────────────────────────────────────
12171248//
12181249// ⛔ A SIGNED-IN BROWSER IS NOT AN IDENTITY. These are separate questions and the surface used to ask
……@@ -1320,11 +1351,13 @@ test('★ POST /reach sends the credential — the half that was never wired', a
13201351 calls.length = 0
13211352 await request('POST', '/reach', {
13221353 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
1323 body: 'reach-bsky=on&credential=did%3Aplc%3Aabc'
1354 body: 'credential=did%3Aplc%3Aabc'
13241355 })
13251356 const set = calls.find((c) => c.cmd === 'set-reach')
1326 t.alike(set.args.networks, ['bsky'])
13271357 t.is(set.args.credential, 'did:plc:abc', 'the DID reaches the daemon')
1358 // ⛔ AND THE NETWORKS ARE NOT TOUCHED. setReach merges, so designating an identity must not also
1359 // be a statement about which networks are on — they are separate decisions and separate submits.
1360 t.absent('networks' in set.args, 'choosing an identity does not switch a network on or off')
13281361})
13291362
13301363// ⚠ Clearing must be possible from the only surface that can say it — withholding is the
……@@ -1510,7 +1543,7 @@ test('★ settings offers to ask a bridge for a name', async (t) => {
15101543 const r = await request('GET', '/settings', { headers: { cookie } })
15111544 t.is(r.status, 200)
15121545 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')
1546 t.ok(/Fediverse bridge key/.test(r.body), 'and it names WHICH network the key belongs to')
15141547})
15151548
15161549test('★★ POST /bridge/claim asks the daemon, then records what the bridge agreed', async (t) => {
……@@ -1596,7 +1629,7 @@ test('★ a published DID with no handle says it resolves for nobody, and offers
15961629 answers.profile = { name: 'x' }; answers.profiles = {}; answers.namespace = null
15971630 answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true, handle: null }
15981631 const r = await request('GET', '/settings', { headers: { cookie } })
1599 t.ok(/nothing can resolve it yet/.test(r.body), 'it does not call this finished')
1632 t.ok(/Bluesky cannot resolve it/.test(r.body), 'it does not call this finished')
16001633 t.ok(r.body.includes('/did/carry'), 'and offers the step that fixes it')
16011634})
16021635
……@@ -1605,7 +1638,7 @@ test('★ once carried, it reports the handle rather than offering again', async
16051638 answers.profile = { name: 'x' }; answers.profiles = {}; answers.namespace = null
16061639 answers['did-status'] = { did: 'did:plc:abc', sealed: true, submitted: true, publishable: true, handle: 'alice.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
16071640 const r = await request('GET', '/settings', { headers: { cookie } })
1608 t.ok(/Reachable as/.test(r.body))
1641 t.ok(/Carried by/.test(r.body))
16091642 t.ok(r.body.includes('alice.7rnx.net'))
16101643 t.absent(r.body.includes('/did/carry'), 'no second offer')
16111644})