OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / be52cb8

0.0.40: a key is an identifier, not a sentence
Measured against Bluesky and Mastodon across all twelve of our pages: five of them printed a full 64-character key as body text — person, knocks, room, search, settings — where it wraps across the column and outweighs everything the reader came for. Neither reference shows an identifier anywhere in reading flow. It was the most consistent difference in the whole audit. ⛔ DEMOTED, NOT DELETED. `who()` says why a key is shown at all: a self-label is a claim, nothing enforces uniqueness, and hiding the key behind a name makes impersonation a matter of typing. That holds for the full key too, which is also how you verify a stranger, follow a feed and join a room. So `keyline()` folds it into a <details> — one click, still selectable, no JavaScript, because every other page here works with scripting off and this must too. ⛔ AND NOTHING BECOMES NAMELESS. Where the key is the ONLY identity we have — a stranger who knocked, a feed publishing no name — a short prefix stays on screen and only the rest folds. Collapsing it there would hide the one thing on the row that says who anybody is. ⚠ The BYLINE key is untouched. It is a documented guard, and Bluesky and Mastodon can drop theirs because their handles are globally unique and server-enforced while ours are namespace-scoped. Changing it on the strength of a visual comparison with a different identity model would be trading a property for an appearance. Also: preview.mjs covered six of fifteen pages, so "audit the design" had meant "audit the timeline". It renders every reader-facing page now, which is how four of these five were found at all. Two things this cost, both mine: - content:'\25b8' in the CSS is an OCTAL escape inside the JS template literal that holds it, and the module stops parsing. The character itself, then. - the test's "what a reader sees" helper stripped tags but left the stylesheet, and a CSS COMMENT here contains the literal string "<details>" — so the disclosure-stripping regex ran from inside the stylesheet to the first real </details> and failed a page that was correct. Substring for structure, the fifth time in this suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
be52cb8e0af19abb18114bc847c20588ae40fdd1
by plankatron <fleeky@gmail.com> · 2026-09-07T21:20:18.000Z

Changed files (4) +150 -10

lib/pages.js +43 -7
……@@ -72,6 +72,24 @@ export function who (author, profiles, { avatar = true } = {}) {
7272 '</span>'
7373}
7474
75/**
76 * A full key, out of the reading flow and never out of reach.
77 *
78 * ⛔ DEMOTED, NOT DELETED. Five pages printed a 64-character key as body text, where it wraps across
79 * the column and outweighs everything a reader came for — the one thing neither Bluesky nor Mastodon
80 * does anywhere. But `who()` above says why a key is shown at all: a self-label is a claim, nothing
81 * enforces uniqueness, and hiding the key behind a name makes impersonation a matter of typing. That
82 * argument holds for the full key too, which is also how you verify a stranger, follow a feed and
83 * join a room. So it collapses; it does not disappear.
84 *
85 * ⚠ `<details>` — no JavaScript. Every other page here works with scripting off and this must too,
86 * and a copy button would be the first thing on the surface that did not.
87 */
88export function keyline (label, key) {
89 return '<details class="keyline"><summary>' + esc(label) + '</summary>' +
90 '<code>' + esc(key) + '</code></details>'
91}
92
7593const CSS = `
7694/* ⛔ ONE colour and ONE spacing scale, as variables. Values scattered through rules is how a page
7795 ends up with four greys that were each "close enough" at the time. */
……@@ -294,7 +312,15 @@ ul.people li{border-top:1px solid var(--line);padding:var(--s2) 0}
294312 padding:.05rem .5rem;white-space:nowrap}
295313/* ⚠ The full key stays — it is the only unforgeable name on the page. It does not need to be the
296314 loudest thing on it: dim and small, so it is available rather than shouting. */
297.fullkey{font-size:12px;color:var(--dim);word-break:break-all;margin:var(--s1) 0}
315.keyline{margin:var(--s1) 0;font-size:12px}
316.keyline summary{color:var(--dim);cursor:pointer;list-style:none;width:fit-content}
317.keyline summary::-webkit-details-marker{display:none}
318/* A disclosure triangle drawn by hand, because the native one is inconsistent across browsers and
319 this sits inline with prose rather than heading a section. */
320.keyline summary::before{content:'▸ ';display:inline-block;transition:transform .12s}
321.keyline[open] summary::before{transform:rotate(90deg)}
322.keyline code{display:block;color:var(--dim);word-break:break-all;margin-top:.25rem;
323 -webkit-user-select:all;user-select:all}
298324img.av{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;object-fit:cover}
299325img.av-lg{width:96px;height:96px;border-radius:50%;object-fit:cover}
300326label{display:block;margin:.4rem 0}
……@@ -661,7 +687,9 @@ export function search (query, results, unreadable, people = [], profiles = null
661687 // not force a follow just to see who somebody is.
662688 '<li><a href="/u/' + esc(h.feed) + '"><b>@' + esc(h.handle) + '</b></a>' +
663689 (h.verdict === 'confirmed' ? '' : ' <span class="who">[' + esc(h.verdict) + ']</span>') +
664 '<br><code>' + esc(h.feed) + '</code>' +
690 // ⚠ A confirmed handle already names them; the key is what you check them AGAINST, which is a
691 // second look rather than the first thing on the row.
692 keyline('feed key', h.feed) +
665693 '<form method="post" action="/follow">' +
666694 '<input type="hidden" name="feed" value="' + esc(h.feed) + '">' +
667695 '<button>follow</button></form></li>').join('') + '</ul>'
……@@ -943,9 +971,11 @@ export function room (key, r, profiles = null) {
943971 const members = '<h2>Members</h2><ul class="people">' + r.members.map((m) =>
944972 '<li>' + who_(m.root) + ' <span class="flag">' + esc(m.role) + '</span></li>').join('') + '</ul>'
945973
974 // ⚠ The sentence stays on screen and the key folds under it: what the key DOES is the part a
975 // reader needs, and it is also the warning.
946976 return layout('rooms', '<h1>' + esc(title) + '</h1>' +
947 '<p class="fullkey"><code>' + esc(key) + '</code></p>' +
948977 '<p class="who">Anyone with this key can read the room. Hand it over to invite somebody.</p>' +
978 keyline('room key', key) +
949979 compose + asks + msgs + members)
950980}
951981
……@@ -1073,8 +1103,12 @@ export function person (author, profile, posts, files = null, holding = null) {
10731103 ? 'You are not rendering their posts. Their feed is still on disk and still replicating.'
10741104 : 'Hides their posts from your timeline, search and mentions. It does not stop them ' +
10751105 'reading your feed or writing about you — nothing here can.') + '</p>'
1076 return layout(p.name || 'profile', pic + name + claim + bio +
1077 '<p class="fullkey"><code>' + esc(author) + '</code></p>' +
1106 // ⚠ When the feed publishes NO name the heading already says so, and the key prefix becomes the
1107 // only thing identifying whose page this is — so it is shown, and the full key still collapses.
1108 const named = !!(p.handle || p.name)
1109 const prefix = named ? '' : '<p class="who"><code>' + short(author) + '…</code></p>'
1110 return layout(p.name || 'profile', pic + name + claim + bio + prefix +
1111 keyline('feed key', author) +
10781112 '<div class="choices">' + mentionable + '</div>' + act + blockBtn + body)
10791113}
10801114
……@@ -1201,7 +1235,7 @@ export function namespaceSettings (namespace, token = null) {
12011235 }
12021236
12031237 return '<h2>Namespace</h2>' + minted +
1204 '<p><code>' + esc(ns.key) + '</code></p>' + status + alone +
1238 keyline('namespace key', ns.key) + status + alone +
12051239 '<p class="who">' + esc(String((ns.admins || []).length)) + ' admin(s), ' +
12061240 esc(String((ns.writers || []).length)) + ' node(s) carrying, ' +
12071241 esc(String(ns.claims == null ? 0 : ns.claims)) + ' handle(s).</p>' +
……@@ -1255,7 +1289,9 @@ export function settings (profile, message, handle = null, namespace = null, tok
12551289
12561290export function knocks (rows) {
12571291 const items = rows.length
1258 ? rows.map((k) => '<li><code>' + esc(k.feed) + '</code>' +
1292 // ⛔ A stranger who knocked has NO profile — we hold nothing of theirs. The key prefix is the
1293 // only thing on the row that says who this is, so it stays on screen and only the rest folds.
1294 ? rows.map((k) => '<li><code>' + short(k.feed) + '…</code>' + keyline('full key', k.feed) +
12591295 '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' +
12601296 '<button>follow them</button></form></li>').join('')
12611297 : '<li>Nobody has knocked.</li>'
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.39",
3 "version": "0.0.40",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
preview.mjs +25 -2
……@@ -5,7 +5,8 @@
55// ⚠ It imports the SAME functions the server calls. A harness with its own markup would drift, and
66// then it would be checking itself rather than the product.
77import fs from 'node:fs'
8import { timeline, person, following, notifications, thread, settings, rooms, room, graph } from './lib/pages.js'
8import { timeline, person, following, notifications, thread, settings, rooms, room, graph,
9 search, mentions, knocks, login } from './lib/pages.js'
910
1011const A = 'ab'.repeat(32)
1112const B = 'cd'.repeat(32)
……@@ -35,7 +36,29 @@ const pages = {
3536 ], profiles),
3637 thread: () => thread(rows[0], [rows[2]], profiles, null, A + '/post/p1'),
3738 settings: () => settings(profiles[A], null, 'tabertron0',
38 { key: 'cd'.repeat(32), claims: 3, founded: true, admins: ['x'], writers: [], requests: [], carrying: true })
39 { key: 'cd'.repeat(32), claims: 3, founded: true, admins: ['x'], writers: [], requests: [], carrying: true }),
40 // ⛔ The harness covered six of fifteen pages, so an audit of "the design" was an audit of the
41 // timeline. Every page a READER lands on belongs here; the ones behind a POST (theme, handle,
42 // namespace) are forms inside settings and are covered by it.
43 search: () => search('ups', rows.slice(0, 2), 1, [A], profiles, null,
44 [{ handle: 'tabertron0', name: 'tabertron', feed: A, verdict: 'confirmed' }]),
45 mentions: () => mentions([rows[0]], profiles),
46 rooms: () => rooms([
47 { key: 'ab'.repeat(32), config: { title: 'the fleet' }, members: 3 },
48 { key: 'cd'.repeat(32), config: {}, members: 1 }
49 ]),
50 room: () => room('ab'.repeat(32), {
51 role: 'admin',
52 config: { title: 'the fleet', topic: 'running the boxes' },
53 members: [{ root: 'ef'.repeat(32), role: 'admin' }, { root: '12'.repeat(32), role: 'peer' }],
54 messages: [
55 { author: 'ef'.repeat(32), body: 'ashatron is back up', createdAt: '2026-09-07T16:10:00Z' },
56 { author: '12'.repeat(32), body: 'good — the SATA cable was it', createdAt: '2026-09-07T16:12:00Z' }
57 ],
58 requests: [], listings: []
59 }, profiles),
60 knocks: () => knocks([{ feed: B }]),
61 login: () => login(null, false)
3962}
4063
4164pages.rooms = () => rooms([
test/keys.test.js +81 -0
……@@ -0,0 +1,81 @@
1// ⛔ A KEY IS AN IDENTIFIER, NOT A SENTENCE.
2//
3// Five pages printed a full 64-character key as body text — person, knocks, room, search, settings —
4// where it wraps across the column and dominates everything a reader came for. Neither Bluesky nor
5// Mastodon shows an identifier anywhere in reading flow, and measured against them this was the
6// single most consistent difference across our twelve pages.
7//
8// ⛔ BUT IT IS NEVER REMOVED. `who()` documents why the byline key exists: a self-label is a claim,
9// nothing enforces uniqueness, and hiding the key behind a name would make impersonation a matter
10// of typing. That argument applies to the full key too — it is how you verify, follow, and join. So
11// the key is DEMOTED, not deleted: out of the reading flow, one click away, still selectable.
12//
13// ⛔ AND NOTHING BECOMES NAMELESS. Where the key is the ONLY identity we have — a stranger who
14// knocked, a feed with no profile — a short prefix stays visible. Collapsing it there would hide the
15// only thing on the row that identifies anybody.
16
17import test from 'brittle'
18import { person, knocks, room, search, settings, keyline } from '../lib/pages.js'
19
20const A = 'ab'.repeat(32)
21const B = 'cd'.repeat(32)
22const profiles = { [A]: { handle: 'tabertron0', name: 'tabertron', root: 'ef'.repeat(32) } }
23
24// What a reader SEES.
25//
26// ⛔ THE STYLESHEET COMES OUT FIRST. Stripping tags alone leaves the whole of CSS behind as "text",
27// and a CSS COMMENT here contains the literal string "<details>" — so the disclosure-stripping regex
28// ran from inside the stylesheet to the first real </details> and swallowed the markup in between,
29// failing a page that was correct. Substring for structure, for the fifth time in this suite.
30const body = (html) => html.replace(/<style[\s\S]*?<\/style>/g, ' ')
31const visible = (html) => body(html).replace(/<[^>]+>/g, ' ')
32// The same, with every <details> body removed — what is on screen before anyone clicks.
33const atRest = (html) => body(html).replace(/<details[\s\S]*?<\/details>/g, ' ').replace(/<[^>]+>/g, ' ')
34const longestKey = (s) => {
35 const hits = s.match(/[0-9a-f]{16,}/g) || []
36 return hits.length ? Math.max(...hits.map((h) => h.length)) : 0
37}
38
39const PAGES = {
40 person: () => person(A, profiles[A], [], null, { held: true, following: true }),
41 knocks: () => knocks([{ feed: B }]),
42 room: () => room(A, { role: 'admin', config: { title: 'the fleet' }, members: [], messages: [], requests: [], listings: [] }, profiles),
43 search: () => search('x', [], 0, [], profiles, null, [{ handle: 'tabertron0', name: 'tabertron', feed: A, verdict: 'confirmed' }]),
44 settings: () => settings(profiles[A], null, 'tabertron0', { key: B, claims: 1, founded: true, admins: [], writers: [], requests: [], carrying: true })
45}
46
47test('★★ no page puts a FULL key in the reading flow', async (t) => {
48 for (const name of Object.keys(PAGES)) {
49 const html = PAGES[name]()
50 t.ok(longestKey(atRest(html)) < 64, name + ': nothing 64 chars long is on screen at rest')
51 }
52})
53
54test('⛔ but the full key is STILL THERE, one click away', async (t) => {
55 // Demoted, not deleted. It is how you verify a stranger, follow a feed and join a room, and a
56 // page that dropped it would make those things impossible from the interface.
57 for (const name of Object.keys(PAGES)) {
58 const html = PAGES[name]()
59 t.ok(longestKey(visible(html)) >= 64, name + ': the key is on the page')
60 t.ok(html.includes('<details'), name + ': behind a disclosure, which needs no JavaScript')
61 }
62})
63
64test('⛔ where the key is the ONLY name, a prefix stays visible', async (t) => {
65 // A stranger who knocked has no profile: we hold nothing of theirs. Collapsing their key hides
66 // the only thing on the row that says who knocked.
67 const html = knocks([{ feed: B }])
68 t.ok(/cdcdcdcd/.test(atRest(html)), 'a short prefix is on screen')
69 // Same for a feed we hold nothing about.
70 const nameless = person(B, null, [], null, { held: false })
71 t.ok(/cdcdcdcd/.test(atRest(nameless)), 'a profile with no name still shows one')
72})
73
74test('⚠ keyline is the ONE way this is done', async (t) => {
75 // A helper rather than five hand-rolled disclosures, because the next page to print a key will
76 // copy whatever the last one did.
77 const out = keyline('feed key', A)
78 t.ok(out.startsWith('<details'))
79 t.ok(out.includes(A), 'carries the whole key')
80 t.ok(out.includes('<summary>'), 'and says what it is before you open it')
81})