| … | … | @@ -72,6 +72,24 @@ export function who (author, profiles, { avatar = true } = {}) { |
| 72 | 72 | '</span>' |
| 73 | 73 | } |
| 74 | 74 | |
| 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 | */ | |
| 88 | export function keyline (label, key) { | |
| 89 | return '<details class="keyline"><summary>' + esc(label) + '</summary>' + | |
| 90 | '<code>' + esc(key) + '</code></details>' | |
| 91 | } | |
| 92 | ||
| 75 | 93 | const CSS = ` |
| 76 | 94 | /* ⛔ ONE colour and ONE spacing scale, as variables. Values scattered through rules is how a page |
| 77 | 95 | 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} |
| 294 | 312 | padding:.05rem .5rem;white-space:nowrap} |
| 295 | 313 | /* ⚠ The full key stays — it is the only unforgeable name on the page. It does not need to be the |
| 296 | 314 | 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} | |
| 298 | 324 | img.av{width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:.35rem;object-fit:cover} |
| 299 | 325 | img.av-lg{width:96px;height:96px;border-radius:50%;object-fit:cover} |
| 300 | 326 | label{display:block;margin:.4rem 0} |
| … | … | @@ -661,7 +687,9 @@ export function search (query, results, unreadable, people = [], profiles = null |
| 661 | 687 | // not force a follow just to see who somebody is. |
| 662 | 688 | '<li><a href="/u/' + esc(h.feed) + '"><b>@' + esc(h.handle) + '</b></a>' + |
| 663 | 689 | (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) + | |
| 665 | 693 | '<form method="post" action="/follow">' + |
| 666 | 694 | '<input type="hidden" name="feed" value="' + esc(h.feed) + '">' + |
| 667 | 695 | '<button>follow</button></form></li>').join('') + '</ul>' |
| … | … | @@ -943,9 +971,11 @@ export function room (key, r, profiles = null) { |
| 943 | 971 | const members = '<h2>Members</h2><ul class="people">' + r.members.map((m) => |
| 944 | 972 | '<li>' + who_(m.root) + ' <span class="flag">' + esc(m.role) + '</span></li>').join('') + '</ul>' |
| 945 | 973 | |
| 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. | |
| 946 | 976 | return layout('rooms', '<h1>' + esc(title) + '</h1>' + |
| 947 | '<p class="fullkey"><code>' + esc(key) + '</code></p>' + | |
| 948 | 977 | '<p class="who">Anyone with this key can read the room. Hand it over to invite somebody.</p>' + |
| 978 | keyline('room key', key) + | |
| 949 | 979 | compose + asks + msgs + members) |
| 950 | 980 | } |
| 951 | 981 | |
| … | … | @@ -1073,8 +1103,12 @@ export function person (author, profile, posts, files = null, holding = null) { |
| 1073 | 1103 | ? 'You are not rendering their posts. Their feed is still on disk and still replicating.' |
| 1074 | 1104 | : 'Hides their posts from your timeline, search and mentions. It does not stop them ' + |
| 1075 | 1105 | '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) + | |
| 1078 | 1112 | '<div class="choices">' + mentionable + '</div>' + act + blockBtn + body) |
| 1079 | 1113 | } |
| 1080 | 1114 | |
| … | … | @@ -1201,7 +1235,7 @@ export function namespaceSettings (namespace, token = null) { |
| 1201 | 1235 | } |
| 1202 | 1236 | |
| 1203 | 1237 | return '<h2>Namespace</h2>' + minted + |
| 1204 | '<p><code>' + esc(ns.key) + '</code></p>' + status + alone + | |
| 1238 | keyline('namespace key', ns.key) + status + alone + | |
| 1205 | 1239 | '<p class="who">' + esc(String((ns.admins || []).length)) + ' admin(s), ' + |
| 1206 | 1240 | esc(String((ns.writers || []).length)) + ' node(s) carrying, ' + |
| 1207 | 1241 | 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 |
| 1255 | 1289 | |
| 1256 | 1290 | export function knocks (rows) { |
| 1257 | 1291 | 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) + | |
| 1259 | 1295 | '<form method="post" action="/admit"><input type="hidden" name="feed" value="' + esc(k.feed) + '">' + |
| 1260 | 1296 | '<button>follow them</button></form></li>').join('') |
| 1261 | 1297 | : '<li>Nobody has knocked.</li>' |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.39", | |
| 3 | "version": "0.0.40", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -5,7 +5,8 @@ |
| 5 | 5 | // ⚠ It imports the SAME functions the server calls. A harness with its own markup would drift, and |
| 6 | 6 | // then it would be checking itself rather than the product. |
| 7 | 7 | import fs from 'node:fs' |
| 8 | import { timeline, person, following, notifications, thread, settings, rooms, room, graph } from './lib/pages.js' | |
| 8 | import { timeline, person, following, notifications, thread, settings, rooms, room, graph, | |
| 9 | search, mentions, knocks, login } from './lib/pages.js' | |
| 9 | 10 | |
| 10 | 11 | const A = 'ab'.repeat(32) |
| 11 | 12 | const B = 'cd'.repeat(32) |
| … | … | @@ -35,7 +36,29 @@ const pages = { |
| 35 | 36 | ], profiles), |
| 36 | 37 | thread: () => thread(rows[0], [rows[2]], profiles, null, A + '/post/p1'), |
| 37 | 38 | 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) | |
| 39 | 62 | } |
| 40 | 63 | |
| 41 | 64 | pages.rooms = () => rooms([ |
| … | … | @@ -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 | ||
| 17 | import test from 'brittle' | |
| 18 | import { person, knocks, room, search, settings, keyline } from '../lib/pages.js' | |
| 19 | ||
| 20 | const A = 'ab'.repeat(32) | |
| 21 | const B = 'cd'.repeat(32) | |
| 22 | const 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. | |
| 30 | const body = (html) => html.replace(/<style[\s\S]*?<\/style>/g, ' ') | |
| 31 | const visible = (html) => body(html).replace(/<[^>]+>/g, ' ') | |
| 32 | // The same, with every <details> body removed — what is on screen before anyone clicks. | |
| 33 | const atRest = (html) => body(html).replace(/<details[\s\S]*?<\/details>/g, ' ').replace(/<[^>]+>/g, ' ') | |
| 34 | const 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 | ||
| 39 | const 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 | ||
| 47 | test('★★ 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 | ||
| 54 | test('⛔ 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 | ||
| 64 | test('⛔ 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 | ||
| 74 | test('⚠ 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 | }) |