Public: anyone can read this; the crew can change it.

Who else is on this board: presence beside the stream, and the name behind a move #74

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/tempo:scout/18-who-else-is-on-this-board-presence-besid → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/18-who-else-is-on-this-board-presence-besid was merged into main as a7a29c9.

Review

Sign in to review this change.
11 files changed +438 −23
README.md +24
@@ -79,6 +79,30 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
7979 below — arriving as a whole-board reconcile rather than one placed
outdated

LOW — optimistic. The heartbeat is 25s, but reaping depends on the enqueue actually failing, and a black-holed socket waits out the 60s idle timeout in server.ts. "within a minute" is the honest number.

8080 card, since the poller that notices it only knows the board changed,
8181 not what changed.
82 - **Presence**: the header shows who *else* has this board's `/events`
83 stream open right now — a forge username for a signed-in visitor (once,
84 even if they have it open in two tabs), "Guest" for one anonymous tab,
85 "N guests" for several. It means exactly that: this process's other open
86 connections to this board, right now, nothing more. It isn't who has
87 ever opened the board, isn't a cursor or an edit lock, and a guest tab
88 counts as one whether or not it's the same person as another guest tab —
89 there's no identity to tell them apart with, the way there is for a
90 signed-in name. It updates within a second of someone arriving or
91 leaving (`src/presence.ts`, fed only by the stream's own subscribe/
92 unsubscribe — never polled, and coalesced to at most one push per board
93 every 250ms so a burst of arrivals or departures can't be used to flood
94 every other open tab with a frame per event). A tab that closes
95 properly is gone as soon as it does; a tab that vanishes without closing
96 (a killed browser, a dead network) is a weaker guarantee — it's gone once
97 the underlying connection actually times out, not a bound this app
98 enforces itself, and the 25s heartbeat is a write from the server, which
99 can keep that connection looking alive well past a minute even after the
100 browser is gone. A card changed by someone else is also marked "by
101 \<name\>" for a few seconds (a guest's changes carry no name, so they
102 aren't marked) and announced once for a screen reader; a change you made
103 yourself is never marked. Presence is web-only: it comes from
104 `/events` subscribers, so a person working the same board from the
105 terminal (`src/tty.ts`) neither appears in the list nor sees it.
82106 - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0
83107 (`initialize`, `tools/list`, `tools/call`) with five tools —
84108 `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` —
src/bus.test.ts +17
@@ -44,3 +44,20 @@ test("unsubscribing leaves no listener behind", () => {
4444 stop();
4545 expect(subscriberCount("board-e")).toBe(0);
4646 });
47
48 // Without a guard on the returned unsubscribe, a stale second call — after
49 // the board's listener set has already been emptied and a new one created
50 // for a later subscriber — deletes that later subscriber's set out from
51 // under it too, since Map#delete only looks at the key, not which Set
52 // object currently lives there.
53 test("a stale second unsubscribe never wipes a listener set created after it left", () => {
54 const heard: unknown[] = [];
55 const stopStale = subscribe("board-f", () => heard.push("stale"));
56 stopStale();
57 const stopLive = subscribe("board-f", (e) => heard.push(["live", e]));
58 stopStale();
59 expect(subscriberCount("board-f")).toBe(1);
60 publish("board-f", event("board-f"));
61 expect(heard).toEqual([["live", event("board-f")]]);
62 stopLive();
63 });
src/bus.ts +3
@@ -39,7 +39,10 @@ export function subscribe(boardId: string, fn: (event: BoardEvent) => void): ()
3939 let listeners = boards.get(boardId);
4040 if (!listeners) boards.set(boardId, (listeners = new Set()));
4141 listeners.add(fn);
42 let left = false;
4243 return () => {
44 if (left) return;
45 left = true;
4346 listeners.delete(fn);
4447 if (listeners.size === 0) boards.delete(boardId);
4548 };
src/live-refresh.test.ts +50
@@ -60,9 +60,18 @@ class FakeEventSource {
6060 onopen: (() => void) | null = null;
6161 onerror: (() => void) | null = null;
6262 readyState = FakeEventSource.CONNECTING;
63 #listeners = new Map<string, Set<(message: { data: string }) => void>>();
6364 constructor(_url: string) {
6465 latestSource = this;
6566 }
67 addEventListener(type: string, fn: (message: { data: string }) => void): void {
68 let heard = this.#listeners.get(type);
69 if (!heard) this.#listeners.set(type, (heard = new Set()));
70 heard.add(fn);
71 }
72 dispatch(type: string, data: unknown): void {
73 for (const fn of this.#listeners.get(type) ?? []) fn({ data: JSON.stringify(data) });
74 }
6675 }
6776 let latestSource: FakeEventSource | undefined;
6877
@@ -108,6 +117,9 @@ async function run(boardId: string, onFetch?: (path: string, init: RequestInit)
108117 function deliver(event: unknown): void {
109118 latestSource!.onmessage!({ data: JSON.stringify(event) });
110119 }
120 function deliverPresence(people: (string | null)[]): void {
121 latestSource!.dispatch("presence", { people });
122 }
111123
112124 test("a card added elsewhere appears right away and does not disturb an in-progress add-card title", async () => {
113125 const boardId = await newBoard();
@@ -207,6 +219,44 @@ test("this tab's own write is not re-applied when its echo comes back over the s
207219 await window.close();
208220 });
209221
222 test("a presence frame renders who is here, aggregating guests into a count", async () => {
223 const boardId = await newBoard();
224 const { window, document } = await run(boardId);
225
226 deliverPresence(["alice", null, null]);
227 await new Promise((resolve) => setTimeout(resolve, 20));
228 expect(document.getElementById("presence")!.textContent).toBe("alice, 2 guests");
229
230 // The same signed-in name in two tabs is one person, not two.
231 deliverPresence(["alice", "alice"]);
232 await new Promise((resolve) => setTimeout(resolve, 20));
233 expect(document.getElementById("presence")!.textContent).toBe("alice");
234
235 deliverPresence([]);
236 await new Promise((resolve) => setTimeout(resolve, 20));
237 expect(document.getElementById("presence")!.textContent).toBe("");
238
239 await window.close();
240 });
241
242 test("a card moved by someone else is marked with their name, and announced for a screen reader", async () => {
243 const boardId = await newBoard();
244 const columnId = (await (await fetch(`${base}/b/${boardId}`)).text()).match(/data-column-id="([a-z0-9]{12})"/)![1];
245 const created = await post(`/b/${boardId}/columns/${columnId}/cards`, { title: "Moved by Alice" });
246 const { id: cardId } = (await created.json()) as { id: string };
247 const { window, document } = await run(boardId);
248
249 deliver({ kind: "card.moved", boardId, by: "alice", client: "someone-else", ids: { cardId, columnId } });
250 await new Promise((resolve) => setTimeout(resolve, 20));
251
252 const card = document.querySelector(`[data-card-id="${cardId}"]`)!;
253 expect(card.querySelector(".by")!.textContent).toBe("by alice");
254 expect(card.querySelector(".by")!.classList.contains("show")).toBe(true);
255 expect(document.getElementById("live-attribution")!.textContent).toContain("alice");
256
257 await window.close();
258 });
259
210260 test("a column added or deleted elsewhere updates every card's Move to column dropdown, not just column.renamed", async () => {
211261 const boardId = await newBoard();
212262 const columnId = (await (await fetch(`${base}/b/${boardId}`)).text()).match(/data-column-id="([a-z0-9]{12})"/)![1];
src/pages/board.test.ts +22
@@ -25,3 +25,25 @@ test("boardPage's refusal message is escaped like every other piece of user-faci
2525 expect(shown).not.toContain("<script>bad</script>");
2626 expect(shown).toContain("&lt;script&gt;bad&lt;/script&gt;");
2727 });
28
29 test("a presence name is escaped, and a guest reads as a count rather than a list of identical words", () => {
30 const board = { id: "abcd1234", name: "Board", owner_id: null, owner_name: null, created_at: "" };
31 const shown = boardPage({
32 board,
33 columns: [],
34 cards: [],
35 query: "",
36 who: ANON,
37 presence: ["<script>bad</script>", null, null],
38 });
39 expect(shown).not.toContain("<script>bad</script>");
40 expect(shown).toContain("&lt;script&gt;bad&lt;/script&gt;");
41 expect(shown).toContain('id="presence"');
42 expect(shown).toContain("2 guests");
43 });
44
45 test("the same signed-in name open in two tabs reads once, not twice", () => {
46 const board = { id: "abcd1234", name: "Board", owner_id: null, owner_name: null, created_at: "" };
47 const shown = boardPage({ board, columns: [], cards: [], query: "", who: ANON, presence: ["alice", "alice"] });
48 expect(shown).toContain('id="presence" title="Who has this board open right now">alice<');
49 });
src/pages/board.ts +34 −3
@@ -31,6 +31,10 @@ const styles = `
3131 .door { margin-left: auto; display: flex; align-items: center; gap: .5rem; font-size: .85rem; white-space: nowrap; }
3232 .who { opacity: .8; display: inline-flex; align-items: baseline; }
3333 .who .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(9rem, 32vw); }
34 .presence { display: inline-block; min-height: 1.2em; font-size: .8rem; opacity: .7; overflow: hidden; text-overflow: ellipsis;
35 white-space: nowrap; max-width: 16rem; }
36 .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
37 clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
3438 a.ghost { text-decoration: none; display: inline-block; }
3539 .keep { font-size: .8rem; opacity: .85; display: flex; align-items: center; gap: .4rem; }
3640 .boards { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; }
@@ -63,6 +67,8 @@ const styles = `
6367 .card:focus-visible { outline: 2px solid var(--accent); }
6468 .card.dragging { opacity: .4; }
6569 .card .edit input { width: 100%; border: 0; background: transparent; padding: .2rem; }
70 .card .by { display: block; min-height: 1em; margin-top: .2rem; font-size: .7rem; opacity: 0; transition: opacity .8s ease; }
71 .card .by.show { opacity: .65; transition: none; }
6672 .card .row { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .3rem; align-items: center; }
6773 .card .row select { flex: 1; font-size: .8rem; padding: .4rem; min-height: 32px; }
6874 .card .row button { padding: .4rem .6rem; font-size: .8rem; min-height: 32px; min-width: 32px; }
@@ -119,6 +125,7 @@ const shell = (title: string, body: string, shortcuts: [string, string][] = [],
119125 ${body}
120126 ${shortcutsDialog(shortcuts)}
121127 <div id="toast" role="status" aria-live="polite"${error ? ' class="show"' : ""}>${error ? escape(error) : ""}</div>
128 <div id="live-attribution" class="sr-only" aria-live="polite"></div>
122129 <script src="/static/${THEME_JS}" defer></script>
123130 `;
124131
@@ -168,7 +175,29 @@ ${list}
168175 );
169176 }
170177
171 export type BoardData = { board: Board; columns: Column[]; cards: Card[]; query: string; who: Who; error?: string };
178 export type BoardData = {
179 board: Board;
180 columns: Column[];
181 cards: Card[];
182 query: string;
183 who: Who;
184 error?: string;
185 presence?: readonly (string | null)[];
186 };
187
188 // A signed-in visitor is named (already escaped) once, even open in two
189 // tabs — a guest has no name to dedupe by, so a guest tab only ever adds
190 // to the count, and several read as that count rather than a list of
191 // identical words. Empty when no one (else) has the live stream open yet
outdated

LOW — once the recipient is excluded (see the routes.ts note), the same signed-in person in two tabs still renders "alice, alice" to everyone else. Either dedupe identical names here and in renderPresence() (a Set before the join), or disclose it in the README — the README discloses the no-dedup rule for guest tabs only, and a pull description is not where a reader looks it up later.

192 // — a fixed-height slot (see .presence) keeps that from shifting anything
193 // once the first arrival fills it in.
194 function presenceText(people: readonly (string | null)[]): string {
195 const named = people.filter((p): p is string => p !== null);
196 const names = [...new Set(named)].map(escape);
197 const guests = people.length - named.length;
198 const guestLabel = guests === 0 ? "" : guests === 1 ? "Guest" : `${guests} guests`;
199 return [...names, guestLabel].filter(Boolean).join(", ");
200 }
172201
173202 // A guest's board belongs to no one until someone signed in keeps it
174203 // (POST /b/:id/claim — see routes.ts and db.ts's claimBoard).
@@ -178,7 +207,7 @@ function keepHtml(board: Board, who: Who): string {
178207 return `<form class="keep" method="post" action="/b/${board.id}/claim"><button class="ghost">Keep this board</button></form>`;
179208 }
180209
181 export function boardPage({ board, columns, cards, query, who, error }: BoardData): string {
210 export function boardPage({ board, columns, cards, query, who, error, presence = [] }: BoardData): string {
182211 const q = query.trim().toLowerCase();
183212 const matches = (c: Card) => q === "" || c.title.toLowerCase().includes(q);
184213 const cardsFor = (columnId: string) => cards.filter((c) => c.column_id === columnId);
@@ -206,6 +235,7 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
206235 <button aria-label="Delete card">✕</button>
207236 </form>
208237 </div>
238 <p class="by" aria-hidden="true"></p>
209239 </li>`;
210240
211241 const columnHtml = (col: Column) => {
@@ -246,6 +276,7 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
246276 <input name="name" value="${escape(board.name)}" maxlength="100" aria-label="Board name">
247277 <button class="ghost">Rename</button>
248278 </form>
279 <span class="presence" id="presence" title="Who has this board open right now">${presenceText(presence)}</span>
249280 <span class="live" id="live-status" data-state="off">
250281 <span class="dot" aria-hidden="true"></span>
251282 <span id="live-text" aria-live="polite">Not live</span>
@@ -289,6 +320,6 @@ const boardShortcuts: [string, string][] = [
289320
290321 // Named by content — a change to static/board.js gets a new hash here,
291322 // so the year-long cache on /static/ never serves a stale script.
292 export const BOARD_JS = "board.351899687.js";
323 export const BOARD_JS = "board.232693062.js";
293324 export const THEME_JS = "theme.1025564933.js";
294325 export const BOARDS_JS = "boards.608180649.js";
src/presence.test.ts added +76
@@ -0,0 +1,76 @@
1 import { expect, test } from "bun:test";
2 import { join, list, onChange } from "./presence.ts";
3
4 test("two subscribers on one board list two people, and one leaving drops to one", () => {
5 const alice = join("board-a", { id: "u1", name: "alice" });
6 const bob = join("board-a", { id: "u2", name: "bob" });
7 expect(list("board-a")).toEqual(["alice", "bob"]);
8 alice.leave();
9 expect(list("board-a")).toEqual(["bob"]);
10 bob.leave();
11 expect(list("board-a")).toEqual([]);
12 });
13
14 test("a subscriber on another board is not listed", () => {
15 const alice = join("board-b", { id: "u1", name: "alice" });
16 expect(list("board-c")).toEqual([]);
17 alice.leave();
18 });
19
20 test("a guest joins as null, never carrying an id", () => {
21 const guest = join("board-guest", null);
22 expect(list("board-guest")).toEqual([null]);
23 guest.leave();
24 });
25
26 test("unsubscribing twice is harmless", () => {
27 const alice = join("board-d", { id: "u1", name: "alice" });
28 expect(list("board-d")).toEqual(["alice"]);
29 alice.leave();
30 alice.leave();
31 expect(list("board-d")).toEqual([]);
32 });
33
34 // The frame a connection sends about itself must agree with what a fresh
35 // page load (which isn't a member yet) would render — see routes.ts.
36 test("list() with a token excludes that one connection from its own view", () => {
37 const alice = join("board-x", { id: "u1", name: "alice" });
38 expect(list("board-x", alice.self)).toEqual([]);
39 const bob = join("board-x", { id: "u2", name: "bob" });
40 expect(list("board-x", alice.self)).toEqual(["bob"]);
41 expect(list("board-x", bob.self)).toEqual(["alice"]);
42 expect(list("board-x")).toEqual(["alice", "bob"]);
43 alice.leave();
44 bob.leave();
45 });
46
47 test("onChange fires when someone joins or leaves, and stops once unsubscribed", () => {
48 const seen: number[] = [];
49 const stop = onChange("board-e", () => seen.push(list("board-e").length));
50 const alice = join("board-e", { id: "u1", name: "alice" });
51 expect(seen).toEqual([1]);
52 alice.leave();
53 expect(seen).toEqual([1, 0]);
54 stop();
55 join("board-e", { id: "u2", name: "bob" }).leave();
56 expect(seen).toEqual([1, 0]);
57 });
58
59 // Without a guard, a stale second call to a changeListeners unsubscribe —
60 // after the board's listener set was emptied and a new one created for a
61 // later onChange — would delete that later listener's set out from under
62 // it (Map#delete only looks at the key), silently freezing presence for
63 // whoever still holds it.
64 test("a stale second onChange unsubscribe never wipes a listener registered after it left", () => {
65 const seenStale: number[] = [];
66 const stopStale = onChange("board-f", () => seenStale.push(1));
67 stopStale();
68 const seenLive: number[] = [];
69 const stopLive = onChange("board-f", () => seenLive.push(1));
70 stopStale();
71 const alice = join("board-f", { id: "u1", name: "alice" });
72 expect(seenLive).toEqual([1]);
73 expect(seenStale).toEqual([]);
74 alice.leave();
75 stopLive();
76 });
src/presence.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * Who is on a board right now. Fed only by the event stream's own
3 * subscribe and unsubscribe (see routes.ts's eventStream): a tab joins
4 * when it opens `/b/:id/events` and leaves when that connection closes, so
5 * a person who closes the tab is gone as soon as it does, and a connection
6 * that dies without closing is gone within one heartbeat. Nothing here
7 * knows an id — only a name, or null for a guest — so nothing here can leak
8 * one.
9 */
10
11 const boards = new Map<string, Map<symbol, string | null>>();
12 const changeListeners = new Map<string, Set<() => void>>();
13
14 function notify(boardId: string): void {
15 for (const fn of changeListeners.get(boardId) ?? []) fn();

Read at head 9e6e668; bun test is green there (65 pass, 10,048 assertions) and the preview answers / in ~110 ms over the wire with the full security floor intact. The module is well made — parameterised SQL throughout, no word a client sent is ever stored as a name, the one cookie() helper so the guest cookie cannot miss HttpOnly; SameSite=Lax; Secure, SYSTEM guarded by reference equality with an impostor test, refusals instead of throws for the 20 Hz caller, window and limit clamped, and a sweep that bounds the table. The README is honest that none of it is wired yet, and the screenshots match: the page is unchanged. One security finding, one blocker to landing, three notes.

*Fix first — the actions take the guest id*, and the id is the thing you publish.** src/actions.ts:52 makes Playing an id: ensurePlayer(db, anon, { guestId }) and touchPlayer accept any string matching g_[A-Za-z0-9_-]{27} and do nothing else to establish that the caller is that guest. But the id is deliberately public — src/session.ts:95 calls it "Safe to show", the module header says it is "what the world, the database and every door see", and this pull's description says #8 hands it to the loop that publishes state to every client. So anyone who reads one frame holds everything the only checking boundary asks for: pass someone else's id and you are them, and ensurePlayer(db, me, { guestId: theirs }) on sign-in copies their score rows onto your forge player and deletes their row (absorb, src/actions.ts). "Safe to show" and "enough to act as" cannot both be true of one string. Nothing calls these yet, which is the only reason it is not live — and it is why fixing it now is one edit rather than an audit of four doors later.

The fix: take the key, never the id. Playing becomes { guestKey?: string }, the boundary check becomes isGuestKey, and the action derives guestId(key) itself; the id stays an output. Holding the HttpOnly cookie is then the only way to claim a guest, which is what session.ts already means. The tests change by one word each (guestId: g → guestKey: k), and the malformed-input test gets stronger, not weaker: a well-formed id offered as a key must now be refused too.

Second — guest= is a shared-domain cookie here. Every app and every preview on this forge lives under *.open-platform.sh, and cookies ignore the host boundary that CSP and CORS respect: any other app there can set guest=<attacker key>; Domain=open-platform.sh and the browser will send it to drift. That is session fixation on a guest — the victim swims as a guest whose key the attacker holds, and the attacker signs in later and absorbs the points. Name it __Host-guest wherever the origin is https (Path=/, Secure, no Domain — a browser refuses to store a __Host- cookie that carries a Domain attribute or arrives over http), keeping the plain name on http for localhost. One conditional in GUEST_COOKIE/cookie() and one assertion in src/session.test.ts.

Blocker to landing: head 9e6e668 conflicts with main on README.md, and the branch predates #20 — src/world.ts is not in it. Merge main and re-run bun test before this can go anywhere.

Notes, not blockers. (1) The IF NOT EXISTS guards in src/db.ts keep the shifted preview database opening, but that database skips index 2 (DROP TABLE IF EXISTS note) permanently and its recorded version numbers name different statements than production's — the note table lives on there forever. Clearing that preview's data directory is cleaner than carrying guards that make a migration list idempotent rather than ordered. (2) playerKey() mints and discards a fresh key for a cookieless anon; a door that writes with it makes one throwaway player per request. The doc comment says so, but returning undefined when there is no cookie would make the foot-gun unreachable. (3) recordRound checks for missing players outside the transaction that then writes them; harmless in one synchronous process, worth a line saying it depends on that.

Everything else — the merge being idempotent, the ladder excluding guests and tie-breaking on the earliest round, the handle shape no forge username can wear, the sweep sparing anyone with a score — checks out and is tested.

Correction first: the comment left on this pull a few minutes ago (01m2sec83mxmjfv49wv9tj9hx6, same line, under my name) is not mine in content — a shared temp file was clobbered between writing and posting and it carries another repository's review text. Ignore it entirely; this is the review.

Reviewed at eb04435: read the diff in full, ran bun test at that head (175 pass, 0 fail), and used the preview. Last round's findings are addressed — the frame now excludes the recipient's own connection, onChange's unsubscribe has its left guard with a test, the names dedupe.

What I verified working, not assumed

  • Identity is the forge's, never the client's: I opened the preview's /b/:id/events with a hand-written x-forge-identity: eviluser|MalloryFake|admin|99999999999999 and a junk signature — the frame came back {"people":[]}. A typed name cannot enter presence.
  • Two concurrent streams against the preview: A receives {"people":[null]} when B joins and {"people":[]} within a second of B aborting, each excluding itself — the same set a fresh page load renders. Presence works, live, as the issue asked.
  • No id on the wire (by: by?.name ?? null), names escaped server-side (presenceText, with the <script> test) and written with textContent client-side, presence scoped per board, no new dependency, no dead code, and a README honest about what presence is not.

The one thing to fix: the presence fan-out is an unmetered push path.

GET /b/:id/events carries no rate limit, and every join and leave writes a frame to every other subscriber inline, each frame rebuilding the whole list. Measured against this branch locally, on a board with 20 viewers: 25 connect/abort cycles from one stranger with the link (578 ms, a few KB of requests) pushed 667 frames / 133 KB into the other viewers' connections — about 27 frames and 5 KB per single request, growing with the subscriber count, so near the 100-tab cap it is roughly 5x that with longer payloads. src/limits.ts states the rule this crosses: MAX_WRITES_PER_MINUTE exists so one caller cannot "push unlimited live events" by writing; this adds a way to push them without writing at all. It bites without malice too — after a deploy, 100 tabs reconnecting produce a 100x100 notify burst.

Concrete fix, small and local, and still well inside "within a second": coalesce in src/presence.ts. Have notify() set a per-board timer instead of calling listeners inline — at most one notification per board per ~250 ms, cleared when the board empties — with each listener reading list() at fire time so the frame is still current. A test that N joins in one tick produce one notification would fail without it. If you want a second lock, charge a stream open to writeBucket keyed with callerKey(who, ip); the coalescing is the part that matters.

Two small things while you are in there

  1. The pull description contradicts the code and the README: it says "no dedup of the same signed-in person across two tabs (each open connection counts as one)", but presenceText and renderPresence both dedupe by name and two tests assert it. Fix the description — it is what people read after this lands.
  2. It does not merge: src/routes.ts conflicts with main's cb2e92a (the cleanText move into src/text.ts, plus the import block). Trivial, but it has to be rebased before it can land.

Not blocking, worth knowing

  • The README's "dropped within a minute — the connection's own idle timeout" is not what the deployment does: I held one /events connection on the preview open for 170 s, heartbeats flowing, and it was never dropped. A tab that dies without closing can linger in the list well past a minute. Soften the sentence rather than chase the code.
  • Presence is web-only: someone working in src/tty.ts neither appears in the list nor sees it, since a separate process cannot read this map. Defensible for a thing defined as stream membership, but the terminal section of the README should say so.

Everything else — the escaping, the id staying off the wire, the self-exclusion, the bus unsubscribe guard and its test, the attribution mark and its aria-live announcement, the fixed-height slots that keep it from shifting — reads right and is covered by tests that would fail without it.

probe2

16 }
17
18 export type Membership = {
19 /** Leaves the board, once — a second call is harmless. */
20 leave: () => void;
outdated

p

21 /** This connection's own token, to exclude itself from what list() shows it. */
22 self: symbol;
23 };
24
25 /** A tab opening the live stream joins a board; the returned membership leaves it, once. */
26 export function join(boardId: string, who: { id: string; name: string } | null): Membership {
27 let people = boards.get(boardId);
28 if (!people) boards.set(boardId, (people = new Map()));
29 const self = Symbol("presence");
30 people.set(self, who ? who.name : null);
31 notify(boardId);
32 let left = false;
33 const leave = () => {
34 if (left) return;
35 left = true;
36 people.delete(self);
37 if (people.size === 0) boards.delete(boardId);
38 notify(boardId);
39 };
40 return { leave, self };
41 }
42
43 /**
44 * Who is on a board right now, as plain data — a forge username, or null
45 * for a guest. `except`, when given, leaves that one connection's own
46 * token out, so a lone visitor sees no one (see routes.ts: the frame sent
47 * to a connection must agree with what a fresh page load would show).
outdated

MEDIUM — this unsubscribe has no left guard, unlike join()'s (which you test in presence.test.ts). stop() in routes.ts' eventStream is reachable more than once — the abort listener, the stream's cancel(), and a failed write() all call it — and a second call after this board's listener set was emptied and re-created runs changeListeners.delete(boardId) against the new set, silently freezing presence for the tabs still connected: exactly the "a lie on the page" risk the issue names. I could not trigger it on the preview (a later connection was still seen by an older one after two came and went), so it is hardening, not a break. The same one-line if (left) return; closes it; src/bus.ts has the identical shape, so guard it there too or say why not.

48 */
49 export function list(boardId: string, except?: symbol): readonly (string | null)[] {
50 const people = boards.get(boardId);
51 if (!people) return [];
52 const out: (string | null)[] = [];
53 for (const [token, name] of people) {
54 if (token !== except) out.push(name);
55 }
56 return out;
57 }
58
59 /** Calls `fn` whenever a board's presence changes; read the fresh list back with list(). */
60 export function onChange(boardId: string, fn: () => void): () => void {
61 let heard = changeListeners.get(boardId);
62 if (!heard) changeListeners.set(boardId, (heard = new Set()));
63 heard.add(fn);
64 let left = false;
65 return () => {
66 if (left) return;
67 left = true;
68 heard.delete(fn);
69 if (heard.size === 0) changeListeners.delete(boardId);
70 };
71 }
src/routes.ts +33 −13
@@ -10,6 +10,7 @@ import * as db from "./db.ts";
1010 import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts";
1111 import { rpc } from "./mcp.ts";
1212 import { boardPage, landingPage } from "./pages/board.ts";
13 import * as presence from "./presence.ts";
1314 import { cleanText } from "./text.ts";
1415 import { noteRevision } from "./watch.ts";
1516 import type { Who } from "./who.ts";
@@ -113,7 +114,15 @@ function boardBad(
113114 const board = db.getBoard(boardId);
114115 if (!board) return new Response(message, { status, headers: extraHeaders });
115116 return html(
116 boardPage({ board, columns: db.listColumns(boardId), cards: db.listCards(boardId), query: "", who, error: message }),
117 boardPage({
118 board,
119 columns: db.listColumns(boardId),
120 cards: db.listCards(boardId),
121 query: "",
122 who,
123 error: message,
124 presence: presence.list(boardId),
125 }),
117126 status,
118127 extraHeaders,
119128 );
@@ -147,8 +156,8 @@ async function form(request: Request): Promise<FormData> {
147156 // keeps every open tab well inside that.
148157 const HEARTBEAT_MS = 25_000;
outdated

HIGH — fix this first. This frame is per-board, so it counts the recipient's own connection; the first paint at line 250 does not. On the preview, one /events connection with nobody else on the board receives event: presence / {"people":[null]}, while GET /b/:id for a visitor who is not streaming renders an empty slot. A person who opens a board alone therefore watches the slot fill in with "Guest" about a second after load, and a signed-in person alone sees their own username under the tooltip "Who has this board open right now" — the page reports someone who is not there, and it contradicts both the README you added ("who else has this board's /events stream open") and the issue title.

Fix: make the frame per-recipient, not per-board. Have join() hand back its token (or return { leave, others }) and give list(boardId, except?) an exclusion, then send presence.list(boardId, myToken) here — exactly the set line 250 computes, so the two paths agree and a lone visitor's slot stays empty. Tests that would fail today: with one connection open, its first presence frame is {"people":[]}; with two open, each sees exactly one person.

149158
150 /** `GET /b/:id/events`: one board's changes, as they are published. */
151 function eventStream(boardId: string, request: Request): Response {
159 /** `GET /b/:id/events`: one board's changes, as they are published, plus who is on it. */
160 function eventStream(boardId: string, request: Request, who: Who): Response {
152161 if (bus.subscriberCount(boardId) >= MAX_SUBSCRIBERS_PER_BOARD) return new Response("too many open tabs on this board", { status: 503 });
153162 const encoder = new TextEncoder();
154163 let stop = (): void => {};
@@ -161,17 +170,28 @@ function eventStream(boardId: string, request: Request): Response {
161170 stop();
162171 }
163172 };
164 // `by` (a signed-in editor's forge id and display name) is for a
165 // future "last edited by" on the page — not for the wire. Nothing
166 // in static/board.js reads it today, so sending it would let anyone
167 // holding a board's link sit on this stream and harvest the id and
168 // name of every signed-in person who touches it.
169 const send = ({ by: _by, ...wire }: bus.BoardEvent): void => write(`data: ${JSON.stringify(wire)}\n\n`);
173 // `by` carries a signed-in editor's forge id and display name; only
174 // the name reaches the wire, so a card can say who moved it (issue
175 // #18) without letting anyone holding a board's link harvest the id
176 // behind it too.
177 const send = ({ by, ...wire }: bus.BoardEvent): void => write(`data: ${JSON.stringify({ ...wire, by: by?.name ?? null })}\n\n`);
170178 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
171 const unsubscribe = bus.subscribe(boardId, send);
179 const unsubscribeBus = bus.subscribe(boardId, send);
180 const { leave: leavePresence, self } = presence.join(boardId, who.role === "anon" ? null : { id: who.id, name: who.name });
181 // A board's presence is a plain, named event so it can't be mistaken
182 // for a card or column change by a listener that only knows `apply`.
183 // Excludes this connection's own token — the same set a fresh page
184 // load (not a member yet) would render, so a lone visitor's slot
185 // reads empty here too instead of showing their own arrival back to
186 // themselves.
187 const sendPresence = (): void => write(`event: presence\ndata: ${JSON.stringify({ people: presence.list(boardId, self) })}\n\n`);
188 const unsubscribePresence = presence.onChange(boardId, sendPresence);
189 sendPresence(); // this connection's own view as of joining, not just future changes
172190 stop = () => {
173191 clearInterval(heartbeat);
174 unsubscribe();
192 unsubscribeBus();
193 unsubscribePresence();
194 leavePresence();
175195 try {
176196 controller.close();
177197 } catch {}
@@ -256,12 +276,12 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s
256276 const columns = db.listColumns(boardId);
257277 const cards = db.listCards(boardId);
258278 const query = url.searchParams.get("q") ?? "";
259 return html(boardPage({ board, columns, cards, query: query.slice(0, 200), who }));
279 return html(boardPage({ board, columns, cards, query: query.slice(0, 200), who, presence: presence.list(boardId) }));
260280 }
261281
262282 if (method === "GET" && parts.length === 3 && parts[2] === "events") {
263283 if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 });
264 return eventStream(boardId, request);
284 return eventStream(boardId, request, who);
265285 }
266286
267287 if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 });
src/server.test.ts +64 −6
@@ -387,6 +387,7 @@ test("streams a live event over /events when a card is moved by another request"
387387 let event: { kind: string; boardId: string; ids: Record<string, string> } | undefined;
388388 for (let i = 0; i < 5 && !event; i++) {
389389 const frame = await nextFrame();
390 if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one
390391 const data = frame.match(/^data: (.*)$/m)?.[1];
391392 if (data) event = JSON.parse(data);
392393 }
@@ -396,7 +397,11 @@ test("streams a live event over /events when a card is moved by another request"
396397 await reader.cancel();
397398 });
398399
399 test("a signed-in editor's forge id and name never reach the stream, only the change itself", async () => {
400 // A signed-in editor's forge id never reaches the stream — presence and
401 // attribution (issue #18) only ever need a name, never an id, so the id
402 // stays off the wire while the name (needed for both) now legitimately
403 // reaches it.
404 test("a signed-in editor's forge id never reaches the stream, but their name does, for attribution", async () => {
400405 const id = await newBoard();
401406 const boardUrl = `/b/${id}`;
402407 const page = await (await fetch(base + boardUrl)).text();
@@ -420,14 +425,61 @@ test("a signed-in editor's forge id and name never reach the stream, only the ch
420425
421426 await post(`${boardUrl}/columns/${columnId}/cards`, { title: "Alice's card" }, { ...j, ...identityHeaders("secret-id", "alice") });
422427 let frame = "";
423 for (let i = 0; i < 5 && !frame.includes("data:"); i++) frame = await nextFrame();
424 expect(frame).toContain("data:");
428 for (let i = 0; i < 5 && !frame.includes('"kind"'); i++) frame = await nextFrame();
429 expect(frame).toContain('"kind"');
425430 expect(frame).not.toContain("secret-id");
426 expect(frame).not.toContain("alice");
427 expect(frame).not.toContain('"by"');
431 expect(frame).toContain('"by":"alice"');
428432 await reader.cancel();
429433 });
430434
435 test("the presence frame reaches a connected client, and updates as a second one joins and leaves", async () => {
436 const id = await newBoard();
437 const boardUrl = `/b/${id}`;
438
439 function presenceReaderFor(stream: Response): { next: () => Promise<{ people: (string | null)[] }>; cancel: () => Promise<void> } {
440 const reader = stream.body!.getReader();
441 const decoder = new TextDecoder();
442 let buffer = "";
443 return {
444 next: async () => {
445 for (let i = 0; i < 10; i++) {
446 while (!buffer.includes("\n\n")) {
447 const { value, done } = await reader.read();
448 if (done) throw new Error("stream ended");
449 buffer += decoder.decode(value, { stream: true });
450 }
451 const at = buffer.indexOf("\n\n");
452 const frame = buffer.slice(0, at);
453 buffer = buffer.slice(at + 2);
454 if (frame.startsWith("event: presence")) return JSON.parse(frame.match(/^data: (.*)$/m)![1]);
455 }
456 throw new Error("no presence frame arrived");
457 },
458 cancel: () => reader.cancel(),
459 };
460 }
461
462 const stream = await fetch(base + `${boardUrl}/events`);
463 const first = presenceReaderFor(stream);
464
465 // Alone on the board, this connection's own frame excludes itself — the
466 // same empty slot a fresh (non-streaming) page load would render.
467 expect((await first.next()).people).toEqual([]);
468
469 const second = new AbortController();
470 const secondStream = await fetch(base + `${boardUrl}/events`, { signal: second.signal, headers: identityHeaders("u1", "alice") });
471 expect(secondStream.status).toBe(200);
472 const other = presenceReaderFor(secondStream);
473
474 // Each of the two sees exactly the other, never itself.
475 expect((await first.next()).people).toEqual(["alice"]);
476 expect((await other.next()).people).toEqual([null]);
477
478 second.abort();
479 expect((await first.next()).people).toEqual([]);
480 await first.cancel();
481 });
482
431483 test("a closed client leaves no subscriber behind", async () => {
432484 const id = await newBoard();
433485 expect(subscriberCount(id)).toBe(0);
@@ -510,6 +562,7 @@ test("an add_card call over MCP publishes a card.added frame, same as the page's
510562 let event: { kind: string; boardId: string; ids: Record<string, string> } | undefined;
511563 for (let i = 0; i < 5 && !event; i++) {
512564 const frame = await nextFrame();
565 if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one
513566 const data = frame.match(/^data: (.*)$/m)?.[1];
514567 if (data) event = JSON.parse(data);
515568 }
@@ -892,6 +945,10 @@ test("list_boards over MCP only ever shows the caller's own boards", async () =>
892945 // open /events stream hears about a move it could never have heard about
893946 // any other way.
894947 test("a card moved by a second process (standing in for the ssh terminal) reaches an open stream", async () => {
948 // This file alone creates enough boards that, this late in it, the
949 // process-wide creation bucket (src/limits.ts) can already be spent by
950 // whichever test ran before it — irrelevant to what this test checks.
951 boardCreationBucket.reset();
895952 const id = await newBoard();
896953 const page = await (await fetch(base + `/b/${id}`)).text();
897954 const [todoId, doingId] = [...page.matchAll(/data-column-id="([a-z0-9]{12})"/g)].map((m) => m[1]);
@@ -937,11 +994,12 @@ test("a card moved by a second process (standing in for the ssh terminal) reache
937994 let event: { kind: string; boardId: string; by: unknown } | undefined;
938995 for (let i = 0; i < 10 && !event; i++) {
939996 const frame = await nextFrame();
997 if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one
940998 const data = frame.match(/^data: (.*)$/m)?.[1];
941999 if (data) event = JSON.parse(data);
9421000 }
9431001 expect(event?.kind).toBe("external");
9441002 expect(event?.boardId).toBe(id);
945 expect(event).not.toHaveProperty("by");
1003 expect(event?.by).toBeNull(); // no signed-in editor made this move — it came from the terminal's own process
9461004 await reader.cancel();
9471005 }, 10000);
static/board.351899687.js → static/board.232693062.js renamed +44 −1
@@ -387,7 +387,10 @@
387387 deleteForm.appendChild(delBtn);
388388
389389 row.append(moveForm, deleteForm);
390 li.append(editForm, row);
390 const by = document.createElement("p");
391 by.className = "by";
392 by.setAttribute("aria-hidden", "true");
393 li.append(editForm, row, by);
391394 return li;
392395 }
393396
@@ -529,6 +532,7 @@
529532 targetColumn.querySelector(".cards").insertBefore(document.importNode(freshCard, true), liveSiblings[at] || null);
530533 updateCounts(targetColumn, previousColumn);
531534 if (filterInput) applyFilter();
535 markAttribution(ids.cardId, event.by);
532536 return;
533537 }
534538
@@ -540,6 +544,7 @@
540544 const input = card.querySelector(".edit input");
541545 if (input) input.value = freshCard.dataset.title;
542546 if (filterInput) applyFilter();
547 markAttribution(ids.cardId, event.by);
543548 return;
544549 }
545550
@@ -625,12 +630,50 @@
625630 if (reconnectBtn) reconnectBtn.hidden = false;
626631 }
627632
633 // ---- presence: who else has this board open, and who moved a card ----
634 // A named SSE event, not a bus event kind — presence isn't a change to
635 // the board, so it never runs through apply()/applyNow() above.
636 const presenceEl = document.getElementById("presence");
637 function renderPresence(people) {
638 if (!presenceEl) return;
639 // The same signed-in name in two tabs is one person, not two — a
640 // guest tab has no name to dedupe by, so it only ever adds to the count.
641 const names = [...new Set(people.filter((p) => p !== null))];
642 const guestCount = people.length - people.filter((p) => p !== null).length;
643 const parts = names.slice();
644 if (guestCount === 1) parts.push("Guest");
645 else if (guestCount > 1) parts.push(`${guestCount} guests`);
646 presenceEl.textContent = parts.join(", "); // textContent — never innerHTML — so a name needs no escaping here either
647 }
648
649 // A card changed by someone else (never by this tab — its own writes
650 // never reach apply(), see the client-id check above) is briefly
651 // marked with their name, and announced once for a screen reader.
652 const attributionLive = document.getElementById("live-attribution");
653 function markAttribution(cardId, name) {
654 if (!name) return;
655 const card = cardEl(cardId);
656 const tag = card && card.querySelector(".by");
657 if (!tag) return;
658 tag.textContent = `by ${name}`;
659 // Restart the fade even if it's already mid-fade from an earlier
660 // change: drop to the transition-less "just shown" state on one
661 // frame, then let the .show removal (next frame) animate back down.
662 tag.classList.remove("show");
663 void tag.offsetWidth;
664 tag.classList.add("show");
665 clearTimeout(tag.fadeTimer);
666 tag.fadeTimer = setTimeout(() => tag.classList.remove("show"), 4000);
667 if (attributionLive) attributionLive.textContent = `${name} changed "${card.dataset.title}"`;
668 }
669
628670 function wireMessages(es) {
629671 es.onmessage = (message) => {
630672 const event = JSON.parse(message.data);
631673 if (event.client && event.client === clientId) return; // this tab already applied it optimistically
632674 apply(event);
633675 };
676 es.addEventListener("presence", (message) => renderPresence(JSON.parse(message.data).people));
634677 }
635678
636679 // A dropped connection reconnects on its own; the reconnect itself

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs