OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / d81e9b9

a fourth step on the Bluesky card: publish what you have written
⛔ CONSENT IS NOT AUTHORSHIP, and the card could not say so. Step 3's switch records that posts MAY go to Bluesky; nothing about it writes a record. For the fediverse that really is the last step — the bridge HOLDS the feed and projects from it server-side — but Bluesky's membrane is keyless and serves only what the device SIGNED, so until the device authors them the repo stays empty. Measured on a handset with every visible step reporting success: identity correct, handle resolving to it, relay active and caught up to our exact rev, switch on — and an atproto repo containing one record, hpp's fresh-account `self.hello`, with no app.bsky.actor.profile at all. The appview had never indexed it. The card had nothing to press because the step did not exist. Offered only when there is somewhere for it to go: publishing into an identity nobody carries writes records nothing can resolve, and publishing with the switch off would publish what the switch says must not be. Both are asserted. Two buttons, because this is irreversible. The dry run submits an empty `send`, so anything but an explicit "1" plans and publishes nothing — publication onto a network that does not forget must not happen because a form field went missing. The page says plainly that existing posts go too, since pressing it publishes a history, not a post. The result renders the daemon's own log rather than a count. The withheld lines are the interesting half — "too long for bsky", "a members room reaches nothing" — and a summary cannot answer "what happened to the one I meant". A refusal reads as "nothing was published" rather than as `0 published`: "could not read what the repo holds" and "there was nothing to send" are different facts, and showing the first as a zero invites pressing it again until it duplicates everything. ⚠ The boundary test caught a COMMENT naming ap-bridge — the rule is a blunt string match over lib/, deliberately. Reworded rather than loosened. 234/234 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d81e9b9af2b8d4b5a84228dfec06909ad64b896f
by plankatron <fleeky@gmail.com> · 2026-09-11T22:49:19.000Z

Changed files (3) +130 -1

lib/pages.js +43 -1
……@@ -2139,12 +2139,54 @@ export function blueskySection (did, reach) {
21392139 '<p class="who">Leave empty to publish under the identity above. Nothing is sent to Bluesky until ' +
21402140 'one is chosen.</p><button>save</button></form></details>'
21412141
2142 // ⛔ THE FOURTH STEP, AND IT IS NOT IMPLIED BY THE THIRD. The switch records CONSENT; it does not
2143 // author anything. The fediverse bridge HOLDS your feed and projects from it server-side, so
2144 // switching that on is genuinely all there is to do — but Bluesky's membrane is keyless and serves
2145 // only what this device SIGNED, so until the device authors them the repo stays empty. Measured on a
2146 // handset: identity correct, handle resolving, relay active, switch on, and an atproto repo holding
2147 // one bootstrap record. Every step reported success and nothing had ever been published.
2148 //
2149 // ⚠ Only offered once there is somewhere for it to go. Publishing into an identity nobody carries
2150 // writes records no one can resolve.
2151 const publish = (on && did.handle)
2152 ? '<p><strong>4. Publish what you have written</strong></p>' +
2153 '<p class="who">The switch above says posts MAY go to Bluesky. This is what sends them: your ' +
2154 'profile, and every post whose room is public enough to leave. Posts already there are left ' +
2155 'alone, so running it twice is safe.</p>' +
2156 '<form method="post" action="/did/project">' +
2157 '<button name="send" value="">see what would be published</button> ' +
2158 '<button name="send" value="1">publish now</button></form>' +
2159 '<p class="warn">Publishing sends your existing posts, not just new ones. A post on Bluesky ' +
2160 'federates and cannot be recalled.</p>'
2161 : ''
2162
21422163 return '<h2>Bluesky</h2>' + identity + carried +
21432164 '<p><strong>3. Send posts there</strong></p>' + designate + other +
21442165 switchFor('bsky', 'Send my public posts to Bluesky', on,
21452166 !did.handle ? 'No bridge carries this identity yet, so nothing will be sent even with this on.' : null) +
21462167 '<p class="who">Plain text only, up to 300 characters — longer posts are withheld, not shortened. ' +
2147 'A post sent there cannot be recalled.</p>'
2168 'A post sent there cannot be recalled.</p>' + publish
2169}
2170
2171/**
2172 * What a projection run did, in its own words.
2173 *
2174 * ⛔ THE LOG VERBATIM, not a summary. Every line is a decision about one record — sent, already there,
2175 * withheld and why — and the withheld ones are the interesting half: "too long for bsky", "a members
2176 * room reaches nothing". A count cannot express those, and the question a person actually has after
2177 * pressing this is "what happened to the one I meant".
2178 */
2179export function projectResult (r, message = null) {
2180 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
2181 const head = r.refused
2182 ? '<p class="warn">Nothing was published: ' + esc(r.refused) + '</p>'
2183 : '<p><strong>' + (r.sent || 0) + '</strong> published · ' + (r.skipped || 0) + ' already there · ' +
2184 (r.withheld || 0) + ' withheld' + (r.held ? ' · ' + r.held + ' held back' : '') + '</p>'
2185 const log = Array.isArray(r.log) && r.log.length
2186 ? '<pre>' + esc(r.log.join('\n')) + '</pre>'
2187 : '<p class="who">The run produced no output.</p>'
2188 return layout('publish to bluesky', warn + '<h1>Publishing to Bluesky</h1>' + head + log +
2189 '<p><a href="/settings">back to settings</a></p>')
21482190}
21492191
21502192/**
lib/router.js +24 -0
……@@ -887,6 +887,30 @@ export async function route (req, res, ctx) {
887887 }
888888 }
889889
890 // ⛔ THE STEP NOTHING ELSE PERFORMS. Every other verb on the Bluesky card is about the IDENTITY —
891 // minting it, publishing it, having a bridge carry it, consenting to use it. None of them authors a
892 // record, and the keyless membrane serves only what this device signed, so all four can succeed
893 // while the repo stays empty. That is not a hypothetical: a handset reached "carried by
894 // fleeky.7rnx.net", relay active, switch on, with one bootstrap record in its repo and no profile.
895 //
896 // ⚠ The daemon's own log is rendered rather than summarised — the withheld lines say WHY a post did
897 // not go, and that is the question a person has after pressing this.
898 if (req.method === 'POST' && path === '/did/project') {
899 const form = await body(req)
900 // ⛔ SEND IS OPT-IN PER PRESS. The dry-run button submits an empty value, so anything other than an
901 // explicit "1" plans and publishes nothing — publication onto a network that does not forget is
902 // not a thing to do because a form field was missing.
903 // ⚠ NOT named `send` — that is this router's own response helper, and shadowing it here turns
904 // every successful run into "send is not a function" at the moment it reports what it published.
905 const doSend = String(form.send || '') === '1'
906 try {
907 const r = await client.call('bsky-project', { send: doSend })
908 return send(res, pages.projectResult(r))
909 } catch (err) {
910 return seeOther(res, '/settings?m=' + encodeURIComponent(err.message))
911 }
912 }
913
890914 if (req.method === 'GET' && path === '/bridge/release') {
891915 const reach = await client.call('reach').catch(() => null)
892916 if (!reach || !reach.fediverse) return seeOther(res, '/settings')
test/publish-step.test.js +63 -0
……@@ -0,0 +1,63 @@
1// The step that actually publishes, and why the card needs a fourth one.
2//
3// ⛔ CONSENT IS NOT AUTHORSHIP. Step 3's switch records that posts MAY go to Bluesky. Nothing about it
4// writes a record. The fediverse bridge HOLDS the feed and projects from it server-side, so for that
5// network the switch really is the last step — but Bluesky's membrane is keyless and serves only what
6// the device SIGNED, so until the device authors them the repo stays empty.
7//
8// Measured on a handset, with every visible step reporting success: identity correct, handle
9// resolving, relay active, switch on — and an atproto repo holding one bootstrap record and no
10// profile. The card had no way to say "nothing has been published", because it had nothing to press.
11
12import test from 'brittle'
13import { blueskySection, projectResult } from '../lib/pages.js'
14
15const DID = { did: 'did:plc:example', handle: 'fleeky.7rnx.net', pdsUrl: 'https://pds.7rnx.net', sealed: true }
16const ON = { networks: ['bsky'], credential: 'did:plc:example' }
17
18test('★★★ the publish step appears once there is somewhere for it to go', (t) => {
19 const html = blueskySection(DID, ON)
20 t.ok(/4\. Publish what you have written/.test(html), 'the step is offered')
21 t.ok(/action="\/did\/project"/.test(html), 'and posts to the verb that authors')
22 t.ok(/name="send" value="1"/.test(html), 'with an explicit send')
23 t.ok(/name="send" value=""/.test(html), 'and a dry run beside it, because this cannot be recalled')
24})
25
26test('★★★ it is NOT offered when nothing could resolve what was published', (t) => {
27 // ⛔ Publishing into an identity nobody carries writes records no one can resolve.
28 const uncarried = blueskySection({ ...DID, handle: null }, ON)
29 t.absent(/4\. Publish what you have written/.test(uncarried), 'no bridge carries it yet')
30
31 // ⛔ And never against withheld consent — the button would be publishing something the switch says
32 // must not be published.
33 const off = blueskySection(DID, { networks: [], credential: 'did:plc:example' })
34 t.absent(/4\. Publish what you have written/.test(off), 'the switch is off')
35})
36
37test('★★ it says that existing posts go too, not only new ones', (t) => {
38 const html = blueskySection(DID, ON)
39 t.ok(/existing posts, not just new ones/.test(html),
40 'a person pressing this is publishing their history, and must be told so before they do')
41})
42
43test('★★★ the result shows the daemon\'s own lines, not a count', (t) => {
44 // The withheld lines are the interesting half: "too long for bsky", "a members room reaches
45 // nothing". A summary cannot answer "what happened to the one I meant".
46 const html = projectResult({
47 sent: 1,
48 skipped: 2,
49 withheld: 1,
50 log: [' 1788 [favela,bsky] bsky: SENT 3mvb36hp7mc22', ' 1789 [favela] bsky: withheld — too long for bsky']
51 })
52 t.ok(/1<\/strong> published/.test(html))
53 t.ok(/already there/.test(html))
54 t.ok(/withheld — too long for bsky/.test(html), 'the reason survives to the page')
55})
56
57test('★★ a refusal is reported as publishing nothing, not as a zero', (t) => {
58 // ⛔ "could not read what the repo holds" and "there was nothing to send" are different facts, and
59 // reporting the first as `0 published` invites pressing it again until it duplicates everything.
60 const html = projectResult({ refused: 'could not read what the bsky sink already holds', log: [] })
61 t.ok(/Nothing was published/.test(html))
62 t.ok(/could not read what the bsky sink already holds/.test(html))
63})