OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 442b95b

the composer says which identity it will post as
A favela node hosts MANY DIDs — your own plus every co-owned community — and the composer named none of them. Its only context was the placeholder "post (relays to your hpp, which signs)", so "post" meant "post as whichever DID this session happens to be bound to". Today that sent a post intended for a community out as a personal identity, to bsky, where it cannot be recalled. An hour then went into looking for a sync bug, because the post was exactly where it should be — under the author nobody had been told about. The page header's `whoami` is not this. It answers "who is signed in", in the opposite corner of the page, and with no handle resolved it prints 32 characters of raw DID — which is what the handset shows, and reads as decoration. A control that writes to one of N identities has to name the one it writes to, next to the control, before you type. Rendered only when the identity is KNOWN: canWrite is also true for the node's own relay writer, where the page holds no DID, and "posting as this node" would be the same lie one step quieter. render 375 assertions, full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442b95bb8ec79aac0e8c738990e80004f4c2820d
by plankatron <fleeky@gmail.com> · 2026-08-18T14:01:21.000Z

Changed files (2) +49 -3

lib/render.js +19 -3
……@@ -598,6 +598,10 @@ details.post>summary{cursor:pointer;color:#888;font-style:italic;list-style:reve
598598.cc{flex:none}
599599#img-controls{flex:none}
600600.composerhint{font-size:.78rem;color:var(--fg-faint,#6b7480);margin:.4rem 0 0}
601/* Which identity this form writes as. Above the textarea because it has to be read BEFORE typing, not
602 discovered after posting; strong enough to be seen, quiet enough not to shout on every page. */
603.composerwho{font-size:.8rem;color:var(--fg-faint,#6b7480);margin:0 0 .4rem}
604.composerwho strong{color:var(--fg);font-weight:600}
601605@media(max-width:640px){
602606 /* On a phone the labels alone fill a line, so give the selects room and let each pair own its width. */
603607 .controls{gap:.35rem .5rem}
……@@ -877,15 +881,27 @@ const LANGS = [
877881 ['pl', 'Polski'], ['sv', 'Svenska'], ['da', 'Dansk'], ['fi', 'Suomi'], ['cs', 'Čeština']
878882]
879883
880function composer (canWrite, quoting = null, lists = []) {
884function composer (canWrite, quoting = null, lists = [], viewer = null) {
881885 if (!canWrite) return ''
886 // ⛔ SAY WHICH IDENTITY THIS WRITES AS. A favela node hosts MANY DIDs — your own plus every co-owned
887 // community — and this form named none of them, so "post" meant "post as whichever DID the session
888 // happens to be bound to". On 2026-08-18 that sent a post intended for a community out as a personal
889 // identity, to bsky, where it cannot be recalled. The page header's `whoami` is not this: it answers
890 // "who is signed in" in the opposite corner, and with no handle resolved it prints 32 characters of
891 // raw DID, which reads as decoration.
892 //
893 // Rendered only when the identity is actually KNOWN. `canWrite` is also true for the node's own relay
894 // writer, where this page holds no DID — and a composer that says "posting as this node" when it has
895 // not established that would be the same lie one step quieter.
896 const who = viewer && viewer.did ? (viewer.handle || shortDid(viewer.did)) : null
897 const whoLine = who ? `<p class="composerwho">posting as <strong>${esc(who)}</strong></p>` : ''
882898 // Quoting: a hidden quoteOf plus a PREVIEW of what is being quoted. Without the preview the composer
883899 // looks identical to a normal one and you cannot tell what (or whether) you are quoting.
884900 const q = quoting
885901 ? `<input type="hidden" name="quoteOf" value="${esc(quoting.uri)}">
886902<div class="qpreview">❝ quoting <strong>${esc(quoting.who || shortDid(quoting.did || ''))}</strong>${quoting.text ? `: <span class="qptext">${esc(String(quoting.text).slice(0, 140))}</span>` : ''} <a class="qcancel" href="/">cancel</a></div>`
887903 : ''
888 return `<form class="composer" method="POST" action="/api/post">${q}
904 return `<form class="composer" method="POST" action="/api/post">${whoLine}${q}
889905<label class="sr-only" for="composer-text">what do you want to post?</label><textarea id="composer-text" name="text" rows="2" placeholder="post (relays to your hpp, which signs)… — a line with only --- starts a new post in a thread" style="width:100%;box-sizing:border-box;font:inherit;padding:.5rem" required></textarea>
890906<div class="controls"><label>scope <select name="scope">
891907<option value="public">public</option>
……@@ -1104,7 +1120,7 @@ export function page ({ title = 'favela', heading, viewer = null, active = 'time
11041120${nav(active, viewer)}
11051121</header>
11061122<main id="main" tabindex="-1">
1107${composer(canWrite, quoting, composerLists)}
1123${composer(canWrite, quoting, composerLists, viewer)}
11081124${body}
11091125</main>
11101126${clientScript}
test/render.test.mjs +30 -0
……@@ -817,4 +817,34 @@ has(followListPage({ did: 'did:plc:x', accounts: [{ did: 'did:plc:y', displayNam
817817}
818818function t_is_empty (s, m) { ok(s === '', m) }
819819
820// ── the composer must name the identity it will post AS ───────────────────────────────────────────
821//
822// ⛔ THIS COST A POST GOING OUT AS THE WRONG IDENTITY (2026-08-18). A favela node hosts MANY DIDs — your
823// own plus every co-owned community — and the composer named none of them: its only context was the
824// placeholder "post (relays to your hpp, which signs)". The page header carries `whoami`, but that is a
825// different question ("who is signed in") asked in a different corner of the page, and on a handset with
826// no handle resolved it rendered 32 characters of raw DID. Someone posted believing they were writing as
827// a community and it went out as their personal identity, to bsky, irreversibly.
828//
829// A control that writes to one of N identities must say which one, next to the control.
830{
831 const withHandle = { did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: 'loser.club' }
832 const c1 = timelinePage({ result: { posts: [] }, viewer: withHandle, canWrite: true })
833 has(c1, 'posting as', 'the composer says who it will post as')
834 has(c1, 'loser.club', 'and names the identity by handle when there is one')
835
836 // A handle is frequently null (the phone logs handle:null on every boot), so the fallback is the one
837 // that actually ships. It must be READABLE — a raw did:plc is 32 characters of base32 and reads as
838 // decoration, which is exactly how it got ignored.
839 const noHandle = { did: 'did:plc:wj62ebbusln6gxo5azo6ogzo' }
840 const c2 = timelinePage({ result: { posts: [] }, viewer: noHandle, canWrite: true })
841 has(c2, 'posting as', 'still says it with no handle')
842 has(c2, shortDid('did:plc:wj62ebbusln6gxo5azo6ogzo'), 'falling back to the SHORT did, not the full string')
843 not(c2, 'did:plc:wj62ebbusln6gxo5azo6ogzo<', 'never the raw 32-char did as the label')
844
845 // The identity line belongs to the composer, not the page: no composer, no claim about posting.
846 not(timelinePage({ result: { posts: [] }, viewer: withHandle, canWrite: false }), 'posting as',
847 'a reader who cannot write is told nothing about posting')
848}
849
820850console.log(`\nRENDER OK: ${pass} assertions`)