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

The roster: a public list of who counts toward a quorum, kept by an admin #16

Closed opened this
Closed without merging

ledger/quorum:ledger/4-the-roster-a-public-list-of-who-counts-t → main 2 commits fork 1 thread · 1 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
12 files changed +665 −28
AGENTS.md +4 −3
@@ -27,7 +27,7 @@ preview at its own address, deployed from your branch as you push.
2727
2828 One implementation, many doors. Every operation is a function in
2929 `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and
30 the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`),
30 the pages (`src/routes.ts`, `src/pages/`), the JSON API (`src/routes.ts`),
3131 the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A
3232 door reads its wire format and shapes the answer; it decides nothing. A
3333 rule in an action holds at every door at once; a rule anywhere else is a
@@ -44,8 +44,9 @@ To add a concept:
4444 a test in `src/actions.test.ts` that fails without the rule.
4545 - **A migration** — append to `migrations` in `src/db.ts`. Never edit one
4646 that has run. `bun run src/db.ts --check` shows the version.
47 - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`.
48 With a second page, move them under `src/pages/` and keep one `layout`.
47 - **A page** — a row in `src/routes.ts` and a file under `src/pages/` that
48 exports one function and draws through the one `layout` (add it to the
49 nav there).
4950 - **An API route** — a row in `src/routes.ts` that calls the action and
5051 answers with `answer(...)`; the status follows the failure.
5152 - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence,
README.md +44 −17
@@ -21,20 +21,20 @@ plain function — `(db, who, input)` in, data or a named failure out — and
2121 the page, the JSON API, the MCP server and the terminal all call it and
2222 decide nothing themselves. `AGENTS.md` says how to add to it.
2323
24 | file | what it is |
25 | ---------------------------------- | ------------------------------------------------------------ |
26 | `src/actions.ts` | propose, list, read, withdraw, health — and who may |
27 | `src/who.ts` | who is asking, from the forge's signed headers |
28 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
29 | `src/live.ts` | the event log, the bus, and `GET /events` |
30 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
31 | `src/routes.ts` | every path, in one table |
32 | `src/pages/` | the HTML, rendered on the server: one layout, two pages |
33 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
34 | `src/server.ts` | boot: open the database, serve |
35 | `src/tty.ts` | the terminal, over the same actions |
36 | `src/env.ts` | what the forge told this process |
37 | `static/app.css`, `static/live.js` | the look, and the list that stays current |
24 | file | what it is |
25 | ---------------------------------- | --------------------------------------------------------------- |
26 | `src/actions.ts` | propose, list, read, withdraw, the roster, health — and who may |
27 | `src/who.ts` | who is asking, from the forge's signed headers |
28 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
29 | `src/live.ts` | the event log, the bus, and `GET /events` |
30 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
31 | `src/routes.ts` | every path, in one table |
32 | `src/pages/` | the HTML, rendered on the server: one layout, three pages |
33 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
34 | `src/server.ts` | boot: open the database, serve |
35 | `src/tty.ts` | the terminal, over the same actions |
36 | `src/env.ts` | what the forge told this process |
37 | `static/app.css`, `static/live.js` | the look, and the list that stays current |
3838
3939 ## Who you are
4040
@@ -44,11 +44,38 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4444 - **anon** — anyone. Reads.
4545 - **user** — signed in to the forge and able to see this app. Makes
4646 proposals, as themselves, and withdraws their own.
47 - **admin** — able to write this repository. Withdraws any proposal.
47 - **admin** — able to write this repository. Withdraws any proposal, and
48 keeps the group.
4849
4950 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5051 `/__forge/signout`; the forge answers both on the app's own host.
5152
53 ## The group
54
55 A quorum needs a denominator, so the group is an explicit list of people:
56 `/group` shows it, and **anyone can read it** — signed out, signed in, over
57 the API or over MCP. Only an admin adds and removes, and the page tells a
58 signed-in person whether they are in it. On an empty roster an admin is
59 offered "Add me", which is the ordinary add with their own identity.
60
61 A member is the forge's `person_id` (1–64 of `A-Za-z0-9_.-`) and a name
62 (1–64 characters) that is only ever shown: matching is on the id, so a
63 rename keeps someone in the group. Adding someone already in it changes
64 nothing and is not an error, removing someone who is not in it is
65 `not_found`, and the group holds at most 256 people.
66
67 ```sh
68 curl https://<app>/api/members
69 curl -X POST https://<app>/api/members -H 'content-type: application/json' \
70 -d '{"person_id":"u1","name":"alice"}'
71 curl -X DELETE https://<app>/api/members/u1
72 ```
73
74 In the terminal, `g` shows the group — with j/k and a window, so a full
75 roster does not scroll away — and says whether the account at the keyboard
76 is in it; Esc goes back. The group is read-only there for now; adding and
77 removing are the page, the API and MCP.
78
5279 ## Any interface
5380
5481 The same actions, the same rules, at every door. A script is the person
@@ -88,8 +115,8 @@ withdraw it while it is open, once. `rule` says how it will be decided and
88115 is `majority` for now.
89116
90117 As an MCP server, for Claude Code or Claude Desktop — `list_proposals`,
91 `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action
92 behind `/api/health`, gated the same way):
118 `get_proposal`, `propose`, `withdraw_proposal`, `list_members`, `health`
119 (the same action behind `/api/health`, gated the same way):
93120
94121 ```sh
95122 claude mcp add --transport http __TITLE__ https://<app>/mcp
src/actions.test.ts +99
@@ -3,12 +3,17 @@ import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import {
6 addMember,
67 canWithdraw,
78 getProposal,
89 health,
10 isMember,
11 listMembers,
912 listProposals,
1013 propose,
14 removeMember,
1115 withdrawProposal,
16 ROSTER_MAX,
1217 type AdminHealth,
1318 } from "./actions.ts";
1419 import { migrations, openDb } from "./db.ts";
@@ -211,3 +216,97 @@ test("a schema behind the migration list is not ok", () => {
211216 expect(r.value.schemaVersion).toBe(migrations.length - 1);
212217 behind.close();
213218 });
219 // The roster gets its own file: the cap test fills one to the brim.
220 const roster = () => openDb(mkdtempSync(join(tmpdir(), "app-roster-")));
221
222 test("anyone reads the group; only an admin keeps it", () => {
223 const db = roster();
224 expect(listMembers(db, anon)).toEqual({ ok: true, value: [] });
225 const byAnon = addMember(db, anon, { personId: "u1", name: "alice" });
226 expect(byAnon.ok).toBe(false);
227 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
228 const byUser = addMember(db, alice, { personId: "u1", name: "alice" });
229 expect(byUser.ok).toBe(false);
230 if (!byUser.ok) expect(byUser.error).toBe("forbidden");
231 expect(isMember(db, alice)).toBe(false);
232
233 const added = addMember(db, root, { personId: " u1 ", name: " alice " });
234 expect(added.ok).toBe(true);
235 if (!added.ok) return;
236 expect(added.value.person_id).toBe("u1");
237 expect(added.value.name).toBe("alice");
238 expect(added.value.added_by).toBe("u0");
239 expect(isMember(db, alice)).toBe(true);
240 expect(isMember(db, bob)).toBe(false);
241 expect(isMember(db, anon)).toBe(false);
242 const all = listMembers(db, anon);
243 expect(all.ok && all.value.map((m) => m.person_id)).toEqual(["u1"]);
244 });
245
246 test("adding someone already in the group adds no second row", () => {
247 const db = roster();
248 const first = addMember(db, root, { personId: "u1", name: "alice" });
249 const again = addMember(db, root, { personId: "u1", name: "alice again" });
250 expect(first.ok && again.ok).toBe(true);
251 if (!first.ok || !again.ok) return;
252 expect(again.value.id).toBe(first.value.id);
253 // The name a rename would change is not the identity, and not clobbered.
254 expect(again.value.name).toBe("alice");
255 const all = listMembers(db, anon);
256 expect(all.ok && all.value.length).toBe(1);
257 });
258
259 test("a person id is an id, and a name is sixty-four characters at most", () => {
260 const db = roster();
261 for (const personId of ["u/1", "u 1", "", " ", "a".repeat(65), "é"]) {
262 const r = addMember(db, root, { personId, name: "alice" });
263 expect(r.ok).toBe(false);
264 if (!r.ok) expect(r.error).toBe("invalid");
265 }
266 for (const name of ["", " ", "x".repeat(65)]) {
267 const r = addMember(db, root, { personId: "u1", name });
268 expect(r.ok).toBe(false);
269 if (!r.ok) expect(r.error).toBe("invalid");
270 }
271 expect(addMember(db, root, { personId: "a".repeat(64), name: "x" }).ok).toBe(
272 true,
273 );
274 expect(addMember(db, root, { personId: "u.1-2_3", name: "ok" }).ok).toBe(
275 true,
276 );
277 const bad = removeMember(db, root, { personId: "u/1" });
278 expect(bad.ok).toBe(false);
279 if (!bad.ok) expect(bad.error).toBe("invalid");
280 });
281
282 test("removing someone who is not in the group is not_found", () => {
283 const db = roster();
284 addMember(db, root, { personId: "u1", name: "alice" });
285 const missing = removeMember(db, root, { personId: "u2" });
286 expect(missing.ok).toBe(false);
287 if (!missing.ok) expect(missing.error).toBe("not_found");
288 const byUser = removeMember(db, alice, { personId: "u1" });
289 expect(byUser.ok).toBe(false);
290 if (!byUser.ok) expect(byUser.error).toBe("forbidden");
291 const byAnon = removeMember(db, anon, { personId: "u1" });
292 expect(byAnon.ok).toBe(false);
293 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
294 expect(removeMember(db, root, { personId: "u1" }).ok).toBe(true);
295 expect(isMember(db, alice)).toBe(false);
296 const gone = removeMember(db, root, { personId: "u1" });
297 expect(gone.ok).toBe(false);
298 if (!gone.ok) expect(gone.error).toBe("not_found");
299 });
300
301 test("the group holds two hundred and fifty-six people", () => {
302 const db = roster();
303 for (let i = 0; i < ROSTER_MAX; i++)
304 expect(addMember(db, root, { personId: `p${i}`, name: `p${i}` }).ok).toBe(
305 true,
306 );
307 const full = addMember(db, root, { personId: "one-more", name: "late" });
308 expect(full.ok).toBe(false);
309 if (!full.ok) expect(full.error).toBe("roster_full");
310 // Someone already counted is not a new row, so a full roster still says yes.
311 expect(addMember(db, root, { personId: "p0", name: "p0" }).ok).toBe(true);
312 });
src/actions.ts +106 −1
@@ -46,7 +46,21 @@ export interface FullProposal extends Proposal {
4646 readonly options: readonly Option[];
4747 }
4848
49 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
49 /** A member of the group: the forge's `person_id` is the identity, `name` is only for showing. */
50 export interface Member {
51 readonly id: number;
52 readonly person_id: string;
53 readonly name: string;
54 readonly added_by: string;
55 readonly created_at: string;
56 }
57
58 export type Failure =
59 | "signin_required"
60 | "forbidden"
61 | "not_found"
62 | "invalid"
63 | "roster_full";
5064 export type Result<T> =
5165 | { ok: true; value: T }
5266 | { ok: false; error: Failure; message: string };
@@ -308,3 +322,94 @@ function soundness(db: Database, now = Date.now()): string {
308322 export const canWithdraw = (who: Who, proposal: Proposal): boolean =>
309323 who.role === "admin" ||
310324 (who.role === "user" && who.id === proposal.author_id);
325
326 // The roster — the denominator a quorum is measured against. Anyone may read
327 // it; only an admin changes it. A member is a `person_id` and nothing else:
328 // the name is stored to show and never matched on, so a rename drops no one.
329
330 export const ROSTER_MAX = 256;
331 export const MEMBER_NAME_MAX = 64;
332 const PERSON_ID = /^[A-Za-z0-9_.-]{1,64}$/;
333
334 export function listMembers(db: Database, _who: Who): Result<Member[]> {
335 return ok(
336 db
337 .query<
338 Member,
339 []
340 >("SELECT * FROM member ORDER BY name COLLATE NOCASE ASC, id ASC")
341 .all(),
342 );
343 }
344
345 export function addMember(
346 db: Database,
347 who: Who,
348 input: { personId: string; name: string },
349 ): Result<Member> {
350 if (who.role !== "admin") return mustBeAdmin(who, "add someone to the group");
351 const personId = String(input.personId ?? "").trim();
352 const name = String(input.name ?? "").trim();
353 if (!PERSON_ID.test(personId))
354 return fail(
355 "invalid",
356 "A person id is 1 to 64 of A-Z, a-z, 0-9, dot, dash or underscore.",
357 );
358 if (name.length === 0 || name.length > MEMBER_NAME_MAX)
359 return fail("invalid", `A name is 1 to ${MEMBER_NAME_MAX} characters.`);
360 // Already on the roster: the same request twice is the same roster, and
361 // nothing changed, so nothing is emitted.
362 const already = findMember(db, personId);
363 if (already !== null) return ok(already);
364 const count = db
365 .query<{ n: number }, []>("SELECT count(*) AS n FROM member")
366 .get()!.n;
367 if (count >= ROSTER_MAX)
368 return fail(
369 "roster_full",
370 `The group holds ${ROSTER_MAX} people. Remove someone first.`,
371 );
372 const member = db
373 .query<
374 Member,
375 [string, string, string]
376 >("INSERT INTO member (person_id, name, added_by) VALUES (?1, ?2, ?3) RETURNING *")
377 .get(personId, name, who.id)!;
378 emit("member.added", member);
379 return ok(member);
380 }
381
382 export function removeMember(
383 db: Database,
384 who: Who,
385 input: { personId: string },
386 ): Result<{ person_id: string }> {
387 if (who.role !== "admin")
388 return mustBeAdmin(who, "remove someone from the group");
389 const personId = String(input.personId ?? "").trim();
390 if (!PERSON_ID.test(personId))
391 return fail(
392 "invalid",
393 "A person id is 1 to 64 of A-Z, a-z, 0-9, dot, dash or underscore.",
394 );
395 const member = findMember(db, personId);
396 if (member === null) return fail("not_found", "That person is not a member.");
397 db.query("DELETE FROM member WHERE person_id = ?1").run(personId);
398 emit("member.removed", { person_id: personId });
399 return ok({ person_id: personId });
400 }
401
402 /** Whether the person asking counts toward a quorum. The vote work leans on this. */
403 export const isMember = (db: Database, who: Who): boolean =>
404 who.role !== "anon" && findMember(db, who.id) !== null;
405
406 const findMember = (db: Database, personId: string): Member | null =>
407 db
408 .query<Member, [string]>("SELECT * FROM member WHERE person_id = ?1")
409 .get(personId);
410
411 /** Why someone who is not an admin was refused: sign in first, or you may not. */
412 const mustBeAdmin = (who: Who, what: string): Result<never> =>
413 who.role === "anon"
414 ? fail("signin_required", `Sign in to ${what}.`)
415 : fail("forbidden", `Only an admin can ${what}.`);
src/db.ts +9
@@ -58,6 +58,15 @@ export const migrations: string[] = [
5858 data TEXT NOT NULL,
5959 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
6060 )`,
61 // The roster: who counts toward a quorum. `person_id` is the forge's id and
62 // the only thing matched on, so a rename changes `name` and drops nobody.
63 `CREATE TABLE member (
64 id INTEGER PRIMARY KEY,
65 person_id TEXT NOT NULL UNIQUE,
66 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
67 added_by TEXT NOT NULL,
68 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
69 )`,
6170 ];
6271
6372 export function openDb(dir = dataDir): Database {
src/mcp.ts +8
@@ -10,6 +10,7 @@ import type { Database } from "bun:sqlite";
1010 import {
1111 getProposal,
1212 health,
13 listMembers,
1314 listProposals,
1415 propose,
1516 withdrawProposal,
@@ -118,6 +119,13 @@ export const tools: readonly Tool[] = [
118119 },
119120 run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }),
120121 },
122 {
123 name: "list_members",
124 description:
125 "The group: everyone who counts toward a quorum. Public — anyone may read it.",
126 inputSchema: { type: "object", properties: {} },
127 run: (db, who) => listMembers(db, who),
128 },
121129 {
122130 name: "health",
123131 description:
src/pages/group.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The group: who counts toward a quorum. Anyone can read this page; the add
3 * form and the Remove buttons are drawn for an admin only — but the rule
4 * that stops anyone else lives in the action, so this page only decides
5 * what to draw, never who may.
6 */
7
8 import { MEMBER_NAME_MAX, ROSTER_MAX, type Member } from "../actions.ts";
9 import { escape } from "../http.ts";
10 import type { Who } from "../who.ts";
11 import { ago, layout, toast } from "./layout.ts";
12
13 export function group(input: {
14 who: Who;
15 members: readonly Member[];
16 isMember: boolean;
17 notice?: string | undefined;
18 }): string {
19 const { who, members, isMember, notice } = input;
20 const admin = who.role === "admin";
21 const standing =
22 who.role === "anon"
23 ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=%2Fgroup">Sign in</a> to see whether you are in the group.</p>`
24 : isMember
25 ? `<p><b>You are in the group.</b> <span class="muted">Your vote counts toward a quorum.</span></p>`
26 : `<p><b>You are not in the group.</b> <span class="muted">An admin adds people to it.</span></p>`;
27 // An empty roster and an admin looking at it: the first member is usually
28 // them. It posts to the same action with their own identity — the action
29 // has no idea this button exists. The name is cut to what the action takes,
30 // so a long forge display name is added rather than refused.
31 const addMe =
32 who.role === "admin"
33 ? `<form method="post" action="/members">
34 <input type="hidden" name="person_id" value="${escape(who.id)}">
35 <input type="hidden" name="name" value="${escape(who.name.slice(0, MEMBER_NAME_MAX))}">
36 <button class="button">Add me</button>
37 </form>`
38 : "";
39 const list =
40 members.length === 0
41 ? `<div class="empty">
42 <p>Nobody is in the group yet. A quorum needs a denominator.</p>
43 ${addMe}
44 </div>`
45 : `<ul class="list">${members.map((member) => row(member, admin)).join("")}</ul>`;
46 return layout(
47 `<section class="card">
48 ${standing}
49 ${
50 admin
51 ? `<form class="row row-wrap" method="post" action="/members">
52 <input class="input" name="person_id" placeholder="person id" aria-label="Person id" required maxlength="64" pattern="[A-Za-z0-9_.\\-]+" autocomplete="off">
53 <input class="input" name="name" placeholder="name to show" aria-label="Name" required maxlength="${MEMBER_NAME_MAX}" autocomplete="off">
54 <button class="button">Add to group</button>
55 </form>
56 <p class="muted small">The person id is the forge's, not the display name: a rename keeps them in. Up to ${ROSTER_MAX} people.</p>`
57 : ""
58 }
59 </section>
60 <section id="members" data-live="member.added member.removed">
61 <p class="muted small">${members.length} ${members.length === 1 ? "person counts" : "people count"} toward a quorum.</p>
62 ${list}
63 </section>
64 ${toast(notice)}`,
65 who,
66 "/group",
67 );
68 }
69
70 const row = (member: Member, admin: boolean): string => `<li class="member">
71 <div>
72 <p>${escape(member.name)} <span class="muted mono">${escape(member.person_id)}</span></p>
73 <small class="muted">since <time datetime="${escape(member.created_at)}">${ago(member.created_at)}</time></small>
74 </div>
75 ${
76 admin
77 ? `<form method="post" action="/members/${escape(encodeURIComponent(member.person_id))}/remove"><button class="button button-quiet" aria-label="Remove ${escape(member.name)} from the group">Remove</button></form>`
78 : ""
79 }
80 </li>`;
src/pages/layout.ts +11 −1
@@ -9,6 +9,12 @@ import { commit, description, env, ssh, title, where } from "../env.ts";
99 import { asset, escape } from "../http.ts";
1010 import type { Who } from "../who.ts";
1111
12 /** Every page with a place in the nav, in the order a person meets them. */
13 const PAGES = [
14 { path: "/", label: "Proposals" },
15 { path: "/group", label: "Group" },
16 ] as const;
17
1218 export function layout(body: string, who: Who, path = "/"): string {
1319 const to = encodeURIComponent(path);
1420 const door =
@@ -31,6 +37,10 @@ export function layout(body: string, who: Who, path = "/"): string {
3137 <div>
3238 <h1><a class="plain" href="/">${escape(title)}</a></h1>
3339 <p class="muted">${escape(description)}</p>
40 <nav class="tabs">${PAGES.map(
41 (page) =>
42 `<a class="tab"${page.path === path ? ` aria-current="page"` : ""} href="${page.path}">${escape(page.label)}</a>`,
43 ).join("")}</nav>
3444 </div>
3545 <nav class="row">
3646 <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
@@ -40,7 +50,7 @@ export function layout(body: string, who: Who, path = "/"): string {
4050 <main>${body}</main>
4151 <footer class="muted">
4252 ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
43 <p>Also at <a href="/api/proposals">/api/proposals</a> and <code>POST /mcp</code>, with the same rules.</p>
53 <p>Also at <a href="/api/proposals">/api/proposals</a>, <a href="/api/members">/api/members</a> and <code>POST /mcp</code>, with the same rules.</p>
4454 </footer>
4555 </div>
4656 </body>
src/routes.ts +78 −1
@@ -6,10 +6,14 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 addMember,
910 getProposal,
1011 health,
12 isMember,
13 listMembers,
1114 listProposals,
1215 propose,
16 removeMember,
1317 withdrawProposal,
1418 type Failure,
1519 type Result,
@@ -27,6 +31,7 @@ import {
2731 } from "./http.ts";
2832 import { events } from "./live.ts";
2933 import { rpc } from "./mcp.ts";
34 import { group } from "./pages/group.ts";
3035 import { home } from "./pages/home.ts";
3136 import { missing } from "./pages/layout.ts";
3237 import { proposalPage } from "./pages/proposal.ts";
@@ -43,7 +48,7 @@ export interface Context {
4348 }
4449
4550 export interface Route {
46 readonly method: "GET" | "POST";
51 readonly method: "GET" | "POST" | "DELETE";
4752 readonly path: string;
4853 readonly handle: (ctx: Context) => Response | Promise<Response>;
4954 }
@@ -53,6 +58,7 @@ const STATUS: Record<Failure, number> = {
5358 forbidden: 403,
5459 not_found: 404,
5560 invalid: 422,
61 roster_full: 409,
5662 };
5763
5864 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -146,6 +152,50 @@ export const routes: readonly Route[] = [
146152 ),
147153 },
148154
155 // The group: everyone reads it, an admin keeps it.
156 {
157 method: "GET",
158 path: "/group",
159 handle: (c) => {
160 const { notice, clear } = readNotice(c.request);
161 const members = listMembers(c.db, c.who);
162 return html(
163 group({
164 who: c.who,
165 members: members.ok ? members.value : [],
166 isMember: isMember(c.db, c.who),
167 notice,
168 }),
169 200,
170 clear,
171 );
172 },
173 },
174 {
175 method: "POST",
176 path: "/members",
177 handle: async (c) => {
178 const form = await readForm(c.request);
179 if (form === undefined) return text("too large", 413);
180 return done(
181 addMember(c.db, c.who, {
182 personId: String(form.get("person_id") ?? ""),
183 name: String(form.get("name") ?? ""),
184 }),
185 "/group",
186 );
187 },
188 },
189 {
190 method: "POST",
191 path: "/members/:personId/remove",
192 handle: (c) =>
193 done(
194 removeMember(c.db, c.who, { personId: c.params.personId ?? "" }),
195 "/group",
196 ),
197 },
198
149199 // The JSON API: the same actions, the same identity, plain status codes.
150200 {
151201 method: "GET",
@@ -188,6 +238,33 @@ export const routes: readonly Route[] = [
188238 handle: (c) =>
189239 answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })),
190240 },
241 {
242 method: "GET",
243 path: "/api/members",
244 handle: (c) => answer(listMembers(c.db, c.who)),
245 },
246 {
247 method: "POST",
248 path: "/api/members",
249 handle: async (c) => {
250 const body = await readJson(c.request);
251 if (body === undefined) return json({ error: "too_large" }, 413);
252 const input = fields(body);
253 return answer(
254 addMember(c.db, c.who, {
255 personId: String(input.person_id ?? input.personId ?? ""),
256 name: String(input.name ?? ""),
257 }),
258 201,
259 );
260 },
261 },
262 {
263 method: "DELETE",
264 path: "/api/members/:personId",
265 handle: (c) =>
266 answer(removeMember(c.db, c.who, { personId: c.params.personId ?? "" })),
267 },
191268
192269 // What a probe asks, forever: 200 while the app is sound, 503 when it is
193270 // not, and never a word about anybody's data.
src/server.test.ts +104
@@ -242,6 +242,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
242242 "get_proposal",
243243 "propose",
244244 "withdraw_proposal",
245 "list_members",
245246 "health",
246247 ]);
247248 const call = (name: string, args: unknown, who = {}) =>
@@ -375,6 +376,109 @@ test("boot names what is missing, and never what is in it", () => {
375376 expect(quiet[0]).toContain("FORGE_APP_URL");
376377 });
377378
379 test("/group is public to read and an admin's to keep", async () => {
380 const guest = await (await fetch(base + "/group")).text();
381 expect(guest).toContain("Nobody is in the group yet");
382 expect(guest).not.toContain('action="/members"');
383 expect(guest).toContain("/__forge/signin?to=%2Fgroup");
384
385 const empty = await (await api("/group", {}, root)).text();
386 expect(empty).toContain("Add me");
387 expect(empty).toContain('value="u0"');
388
389 const asUser = await (await api("/group", {}, alice)).text();
390 expect(asUser).toContain("You are not in the group");
391 expect(asUser).not.toContain('action="/members"');
392
393 const form = new FormData();
394 form.set("person_id", "u1");
395 form.set("name", "alice");
396 const refused = await api(
397 "/members",
398 { method: "POST", body: form, redirect: "manual" },
399 alice,
400 );
401 expect(refused.status).toBe(303);
402 expect(refused.headers.get("location")).toBe("/group");
403 expect(refused.headers.get("set-cookie")).toContain("notice=");
404 expect(await (await fetch(base + "/group")).text()).not.toContain(
405 "Remove alice from the group",
406 );
407
408 const added = await api(
409 "/members",
410 { method: "POST", body: form, redirect: "manual" },
411 root,
412 );
413 expect(added.status).toBe(303);
414 expect(added.headers.get("location")).toBe("/group");
415 const page = await (await api("/group", {}, alice)).text();
416 expect(page).toContain("You are in the group");
417 expect(page).toContain("alice");
418 expect(page).not.toContain("Remove alice from the group");
419 expect(await (await api("/group", {}, root)).text()).toContain(
420 "Remove alice from the group",
421 );
422
423 const removed = await api(
424 "/members/u1/remove",
425 { method: "POST", redirect: "manual" },
426 root,
427 );
428 expect(removed.status).toBe(303);
429 expect(await (await api("/group", {}, alice)).text()).toContain(
430 "You are not in the group",
431 );
432 });
433
434 test("the members API reads for a guest and writes for an admin only", async () => {
435 expect(
436 (await api("/api/members", { method: "POST", ...jsonBody({}) })).status,
437 ).toBe(401);
438 expect(
439 (
440 await api(
441 "/api/members",
442 { method: "POST", ...jsonBody({ person_id: "u2", name: "bob" }) },
443 alice,
444 )
445 ).status,
446 ).toBe(403);
447 const created = await api(
448 "/api/members",
449 { method: "POST", ...jsonBody({ person_id: "u2", name: "bob" }) },
450 root,
451 );
452 expect(created.status).toBe(201);
453 expect(((await created.json()) as { person_id: string }).person_id).toBe(
454 "u2",
455 );
456 expect(
457 (
458 await api(
459 "/api/members",
460 { method: "POST", ...jsonBody({ person_id: "u2/x", name: "bob" }) },
461 root,
462 )
463 ).status,
464 ).toBe(422);
465
466 const listed = (await (await fetch(base + "/api/members")).json()) as {
467 person_id: string;
468 }[];
469 expect(listed.map((m) => m.person_id)).toContain("u2");
470
471 expect(
472 (await api("/api/members/u2", { method: "DELETE" }, alice)).status,
473 ).toBe(403);
474 expect(
475 (await api("/api/members/u2", { method: "DELETE" }, root)).status,
476 ).toBe(200);
477 expect(
478 (await api("/api/members/u2", { method: "DELETE" }, root)).status,
479 ).toBe(404);
480 });
481
378482 test("a change is on the event stream, and a late client hears what it missed", async () => {
379483 const stream = await fetch(base + "/events");
380484 expect(stream.headers.get("content-type")).toBe("text/event-stream");
src/tty.ts +75 −5
@@ -2,18 +2,21 @@
22 * The app in a terminal. The forge runs this on a pty when someone
33 * `ssh`es to the app, over the same data as the page and through the same
44 * actions, as the account at the keyboard. j/k move, Enter opens the
5 * proposal under the cursor, n proposes, w withdraws, Esc goes back and q
6 * leaves. Plain ANSI, no dependencies.
5 * proposal under the cursor, n proposes, w withdraws, g shows the group,
6 * Esc goes back and q leaves. Plain ANSI, no dependencies.
77 */
88
99 import {
1010 canWithdraw,
1111 getProposal,
12 isMember,
13 listMembers,
1214 listProposals,
1315 propose,
1416 withdrawProposal,
1517 OPTIONS_MAX,
1618 type FullProposal,
19 type Member,
1720 type ProposalSummary,
1821 } from "./actions.ts";
1922 import { openDb } from "./db.ts";
@@ -42,12 +45,20 @@ let cursor = 0;
4245 let open: number | undefined;
4346 let draft: Draft | undefined;
4447 let said = "";
48 /** The group, under g: the same roster the page shows, read-only here. */
49 let showGroup = false;
50 let groupCursor = 0;
4551
4652 const proposals = (): ProposalSummary[] => {
4753 const listed = listProposals(db, me, { state: "open" });
4854 return listed.ok ? listed.value : [];
4955 };
5056
57 const members = (): Member[] => {
58 const listed = listMembers(db, me);
59 return listed.ok ? listed.value : [];
60 };
61
5162 const one = (id: number): FullProposal | undefined => {
5263 const found = getProposal(db, me, { id });
5364 return found.ok ? found.value : undefined;
@@ -78,6 +89,7 @@ function draw(): void {
7889 "",
7990 ];
8091 if (draft !== undefined) lines.push(...composer(draft));
92 else if (showGroup) lines.push(...groupView());
8193 else if (open !== undefined) lines.push(...detail(open));
8294 else lines.push(...list());
8395 lines.push("");
@@ -101,6 +113,46 @@ const list = (): string[] => {
101113 });
102114 };
103115
116 /**
117 * The roster, with the cursor's row always on screen: 256 people do not fit
118 * in a terminal, so the window follows the cursor rather than the list
119 * running off the top.
120 */
121 const groupView = (): string[] => {
122 const rows = members();
123 groupCursor = Math.min(groupCursor, Math.max(0, rows.length - 1));
124 const lines = [
125 bold(`The group — ${rows.length} counting toward a quorum`),
126 dim(
127 me.role === "anon"
128 ? "Signed out: the group does not count you."
129 : isMember(db, me)
130 ? `${me.name} is in the group.`
131 : `${me.name} is not in the group.`,
132 ),
133 "",
134 ];
135 if (rows.length === 0) return [...lines, dim("Nobody is in the group yet.")];
136 const height = Math.max(3, (process.stdout.rows ?? 24) - 9);
137 const first = Math.min(
138 Math.max(0, groupCursor - Math.floor(height / 2)),
139 Math.max(0, rows.length - height),
140 );
141 const window = rows.slice(first, first + height);
142 window.forEach((member, i) => {
143 const here = first + i === groupCursor;
144 const tail = dim(
145 `${member.person_id} · since ${member.created_at.slice(0, 10)}`,
146 );
147 lines.push(
148 `${here ? "▸" : " "} ${here ? bold(member.name) : member.name} ${tail}`,
149 );
150 });
151 if (first + window.length < rows.length || first > 0)
152 lines.push(dim(`${first + 1}-${first + window.length} of ${rows.length}`));
153 return lines;
154 };
155
104156 const detail = (id: number): string[] => {
105157 const proposal = one(id);
106158 if (proposal === undefined) return [dim("That proposal is gone.")];
@@ -138,10 +190,12 @@ const composer = (d: Draft): string[] => {
138190
139191 const keys = (): string => {
140192 if (draft !== undefined) return "Enter accepts · Esc cancels";
141 if (open !== undefined) return "Esc back · w withdraw · q quit";
193 if (showGroup)
194 return "j/k move · Esc back · q quit · the group is read-only here";
195 if (open !== undefined) return "Esc back · w withdraw · g group · q quit";
142196 return me.role === "anon"
143 ? "j/k move · Enter open · q quit · sign in through the forge to propose"
144 : "j/k move · Enter open · n propose · w withdraw · q quit";
197 ? "j/k move · Enter open · g group · q quit · sign in through the forge to propose"
198 : "j/k move · Enter open · n propose · w withdraw · g group · q quit";
145199 };
146200
147201 /** The draft, finished: the same action every other door calls. */
@@ -221,6 +275,22 @@ process.stdin.on("data", (key: string) => {
221275 out(`${esc}2J${esc}H`);
222276 process.exit(0);
223277 }
278 if (showGroup) {
279 const roster = members();
280 if (key === "j" || key === "\x1b[B")
281 groupCursor = Math.min(groupCursor + 1, Math.max(0, roster.length - 1));
282 if (key === "k" || key === "\x1b[A")
283 groupCursor = Math.max(groupCursor - 1, 0);
284 if (key === "\x1b" || key === "g") showGroup = false;
285 draw();
286 return;
287 }
288 if (key === "g") {
289 showGroup = true;
290 groupCursor = 0;
291 draw();
292 return;
293 }
224294 const rows = proposals();
225295 const under = open ?? rows[cursor]?.id;
226296 if (key === "j" || key === "\x1b[B")
static/app.css +47
@@ -146,6 +146,53 @@ code,
146146 color: inherit;
147147 text-decoration: none;
148148 }
149 .member {
150 display: flex;
151 justify-content: space-between;
152 align-items: flex-start;
153 gap: var(--s3);
154 padding: var(--s3) 0;
155 border-top: 1px solid var(--line);
156 }
157 .member:first-child {
158 border-top: 0;
159 }
160 .member p {
161 overflow-wrap: anywhere;
162 }
163 .member small {
164 font-size: var(--text-sm);
165 }
166 /* Two fields and a button: one row on a desktop, stacked on a phone. */
167 .row-wrap {
168 flex-wrap: wrap;
169 margin-bottom: var(--s2);
170 }
171 .row-wrap .input {
172 flex: 1 1 10rem;
173 }
174 /* The pages, one per concept. */
175 .tabs {
176 display: flex;
177 gap: var(--s2);
178 margin-top: var(--s2);
179 }
180 .tab {
181 display: inline-flex;
182 align-items: center;
183 min-height: var(--tap);
184 padding: 0 var(--s2);
185 color: var(--muted);
186 text-decoration: none;
187 border-bottom: 2px solid transparent;
188 }
189 .tab[aria-current="page"] {
190 color: var(--fg);
191 border-bottom-color: var(--accent);
192 }
193 .tab:hover {
194 color: var(--fg);
195 }
149196 .proposal {
150197 display: flex;
151198 justify-content: space-between;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs