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

Deliberation: comments, questions and counter-proposals on a proposal #32

Merged opened this

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

Merged

ledger/quorum:ledger/6-deliberation-comments-questions-and-coun → main 2 commits fork

  • Merged. ledger/6-deliberation-comments-questions-and-coun was merged into main as 8fd7082.

Review

Sign in to review this change.
13 files changed +1369 −47
README.md +57 −11
@@ -23,8 +23,9 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2323
2424 | file | what it is |
2525 | ---------------------------------- | ------------------------------------------------------------ |
26 | `src/actions.ts` | propose, list, read, withdraw, health — and who may |
26 | `src/actions.ts` | propose, list, read, withdraw, remark, health — and who may |
2727 | `src/quorum.ts` | the three rules as plain data: read one, say it, count it |
28 | `src/plain.ts` | somebody else's text, safe to print on a terminal |
2829 | `src/who.ts` | who is asking, from the forge's signed headers |
2930 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3031 | `src/live.ts` | the event log, the bus, and `GET /events` |
@@ -44,8 +45,11 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4445
4546 - **anon** — anyone. Reads.
4647 - **user** — signed in to the forge and able to see this app. Makes
47 proposals, as themselves, and withdraws their own.
48 - **admin** — able to write this repository. Withdraws any proposal.
48 proposals, as themselves, withdraws their own, says things on anybody's
49 and deletes what they said; on a proposal they opened, they can accept a
50 counter.
51 - **admin** — able to write this repository. Withdraws any proposal, deletes
52 any remark, accepts a counter on any proposal.
4953
5054 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5155 `/__forge/signout`; the forge answers both on the app's own host.
@@ -64,6 +68,14 @@ names and count a `named:` rule needs; `/p/<id>` says the rule in words above
6468 the options. A guest sees both pages and no forms. With nothing proposed yet,
6569 `/` says in four sentences what a proposal is rather than that it is empty.
6670
71 Under the proposal is the argument about it: a composer for whoever is signed
72 in, and then the thread, oldest first, each remark labelled with its kind. The
73 thread is the last thing on the page and grows at the bottom, so a remark
74 arriving live moves nothing above it — least of all the box somebody is typing
75 in. An author sees a Delete on their own remark and an admin on every one; the
76 proposal's author and an admin see Accept on a counter that names an option
77 the proposal does not have yet.
78
6779 `/about`, linked from the footer of every page, is the longer version: how a
6880 proposal is decided, the three rules printed from `src/quorum.ts`, two plain
6981 lists of what is built and what is not (voting, deliberation, a roster and
@@ -79,6 +91,11 @@ curl https://<app>/api/proposals/1 # one, with its options in order
7991 curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \
8092 -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}'
8193 curl -X POST https://<app>/api/proposals/1/withdraw
94 curl https://<app>/api/proposals/1/remarks # the thread, oldest first
95 curl -X POST https://<app>/api/proposals/1/remarks -H 'content-type: application/json' \
96 -d '{"kind":"counter","body":"Neither room is warm in winter.","optionLabel":"The library"}'
97 curl -X POST https://<app>/api/remarks/1/accept # the proposal's author, or an admin
98 curl -X DELETE https://<app>/api/remarks/1 # its author, or an admin
8299 curl https://<app>/api/health # 200 while sound, 503 when it is not
83100 ```
84101
@@ -111,9 +128,34 @@ named:alice,bob,carol:2 2 of those three agree; nobody else counts
111128 Anything else is refused with a sentence naming the three forms, and no rule
112129 at all means `majority`. A proposal keeps the rule it was opened with.
113130
131 ## Deliberation
132
133 The argument happens on the proposal and stays part of the record. A remark
134 is 1 to 2000 characters by a signed-in person, and it is one of three kinds:
135
136 - **comment** — something to say about the proposal: an opinion, a fact, a
137 caveat, anything that is not a question or a demand for another option.
138 - **question** — something to ask its author, asked in the open so the
139 answer is in the open too.
140 - **counter** — an argument for another option, which may name the option it
141 wants in `optionLabel` (1–80 characters).
142
143 A remark's author or an admin deletes it. The proposal's author or an admin
144 can **accept** a counter that names an option, while the proposal is open:
145 the label is appended to the proposal as one more option and the counter is
146 stamped as accepted. Nothing else about the proposal moves, so every ballot
147 already cast still means what it meant — the page says so where the accepted
148 counter is. Accepting twice, accepting a counter that names nothing, naming
149 an option the proposal already has, and accepting on a proposal that is
150 withdrawn or decided are each refused.
151
152 A withdrawn or decided proposal still takes remarks: the record continues
153 after the decision, and an argument about what was decided belongs where the
154 decision is.
155
114156 As an MCP server, for Claude Code or Claude Desktop — `list_proposals`,
115 `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action
116 behind `/api/health`, gated the same way):
157 `get_proposal`, `propose`, `withdraw_proposal`, `list_remarks`, `add_remark`,
158 `health` (the same action behind `/api/health`, gated the same way):
117159
118160 ```sh
119161 claude mcp add --transport http __TITLE__ https://<app>/mcp
@@ -126,7 +168,8 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp
126168 ```
127169
128170 The page stays current over `/events`: a proposal someone else opens or
129 withdraws appears without a reload — including one written at the terminal,
171 withdraws, and a remark someone else writes, deletes or has accepted, appears
172 without a reload — including one written at the terminal,
130173 which is another process on the same file. Every event is a row in `event`,
131174 so the server follows what it did not write itself while anyone is
132175 watching, and a browser that reconnects with `Last-Event-ID` is replayed
@@ -138,11 +181,14 @@ The terminal opens with the exact command in `FORGE_APP_SSH` when the app
138181 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
139182 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
140183 a real pty over the same data, as the account at the keyboard: `j`/`k`
141 moves, Enter opens the proposal under the cursor, `n` proposes (a title,
142 then the paragraph, then options one per line until a blank line, then the
143 rule — Enter alone for `majority`), `w` withdraws, Esc goes back and `q`
144 quits. A guest is told to sign in rather
145 than shown a composer.
184 moves, Enter opens the proposal under the cursor — with the thread on it
185 underneath — `n` proposes (a title, then the paragraph, then options one per
186 line until a blank line, then the rule — Enter alone for `majority`), `c`
187 says something (`1`, `2` or `3` for the kind, then the text, then the option
188 a counter wants), `w` withdraws, Esc goes back and `q` quits. A guest is told
189 to sign in rather than shown a composer. Nothing anyone else wrote reaches
190 that screen as a control character: it goes through `src/plain.ts` on the way
191 out, so the only escape sequences a terminal here sees are the app's own.
146192
147193 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
148194 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
src/actions.test.ts +285
@@ -3,10 +3,15 @@ import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import {
6 acceptCounter,
7 addRemark,
8 canDeleteRemark,
69 canWithdraw,
10 deleteRemark,
711 getProposal,
812 health,
913 listProposals,
14 listRemarks,
1015 propose,
1116 withdrawProposal,
1217 type AdminHealth,
@@ -247,6 +252,286 @@ test("a state that is not a state is refused, and a limit is bounded", () => {
247252 expect(capped.ok && capped.value.length).toBe(1);
248253 });
249254
255 /** A remark, or the reason it was refused, so a test reads as one line. */
256 const say = (
257 who: Who,
258 proposalId: number,
259 input: Record<string, unknown> = {},
260 ) =>
261 addRemark(db, who, {
262 proposalId,
263 kind: "comment",
264 body: "Sounds right to me.",
265 ...input,
266 } as { proposalId: number; kind: string; body: string });
267
268 test("anyone reads the thread; only a signed-in person writes in it", () => {
269 const p = made(alice);
270 expect(listRemarks(db, anon, { proposalId: p.id })).toEqual({
271 ok: true,
272 value: [],
273 });
274
275 const refused = say(anon, p.id);
276 expect(refused.ok).toBe(false);
277 if (!refused.ok) expect(refused.error).toBe("signin_required");
278
279 const first = say(bob, p.id, { body: " The kitchen has no chairs. " });
280 expect(first.ok && first.value.body).toBe("The kitchen has no chairs.");
281 expect(first.ok && first.value.kind).toBe("comment");
282 expect(first.ok && first.value.author_name).toBe("bob");
283 expect(first.ok && first.value.option_label).toBeNull();
284 expect(first.ok && first.value.accepted_at).toBeNull();
285 const second = say(alice, p.id, {
286 kind: "question",
287 body: "Whose chairs are they?",
288 });
289 expect(second.ok).toBe(true);
290
291 // Oldest first, and a guest reads all of it.
292 const thread = listRemarks(db, anon, { proposalId: p.id });
293 expect(thread.ok && thread.value.map((r) => r.kind)).toEqual([
294 "comment",
295 "question",
296 ]);
297 expect(thread.ok && thread.value.map((r) => r.body)).toEqual([
298 "The kitchen has no chairs.",
299 "Whose chairs are they?",
300 ]);
301
302 // The thread belongs to its proposal and to no other.
303 const other = made(alice);
304 expect(listRemarks(db, anon, { proposalId: other.id })).toEqual({
305 ok: true,
306 value: [],
307 });
308 const nowhere = listRemarks(db, anon, { proposalId: 9999 });
309 expect(nowhere.ok).toBe(false);
310 if (!nowhere.ok) expect(nowhere.error).toBe("not_found");
311 });
312
313 test("a remark is one of three kinds, 1 to 2000 characters, on a proposal that is there", () => {
314 const p = made(alice);
315 for (const input of [
316 { kind: "shout" },
317 { kind: "" },
318 { kind: "COMMENT" },
319 { body: "" },
320 { body: " " },
321 { body: "x".repeat(2001) },
322 // Only a counter names an option, and a label has a length like any other.
323 { kind: "comment", optionLabel: "Neither" },
324 { kind: "counter", optionLabel: "x".repeat(81) },
325 ]) {
326 const r = say(bob, p.id, input);
327 expect(r.ok, JSON.stringify(input).slice(0, 40)).toBe(false);
328 if (!r.ok) {
329 expect(r.error).toBe("invalid");
330 expect(r.message.length).toBeGreaterThan(10);
331 }
332 }
333 expect(say(bob, p.id, { body: "x".repeat(2000) }).ok).toBe(true);
334 const nowhere = say(bob, 9999);
335 expect(nowhere.ok).toBe(false);
336 if (!nowhere.ok) expect(nowhere.error).toBe("not_found");
337 const odd = say(bob, 1.5);
338 expect(odd.ok).toBe(false);
339 if (!odd.ok) expect(odd.error).toBe("invalid");
340 });
341
342 test("the record continues after the decision: a withdrawn proposal still takes remarks", () => {
343 const p = made(alice);
344 expect(withdrawProposal(db, alice, { id: p.id }).ok).toBe(true);
345 const after = say(bob, p.id, {
346 kind: "question",
347 body: "Why was this dropped?",
348 });
349 expect(after.ok).toBe(true);
350 expect(listRemarks(db, anon, { proposalId: p.id }).ok).toBe(true);
351 });
352
353 test("the author deletes their own remark, an admin deletes any", () => {
354 const p = made(alice);
355 const bobs = say(bob, p.id);
356 if (!bobs.ok) throw new Error(bobs.message);
357 expect(canDeleteRemark(bob, bobs.value)).toBe(true);
358 expect(canDeleteRemark(alice, bobs.value)).toBe(false);
359 expect(canDeleteRemark(root, bobs.value)).toBe(true);
360 expect(canDeleteRemark(anon, bobs.value)).toBe(false);
361
362 // The proposal's author is not the remark's author.
363 const byAlice = deleteRemark(db, alice, { id: bobs.value.id });
364 expect(byAlice.ok).toBe(false);
365 if (!byAlice.ok) expect(byAlice.error).toBe("forbidden");
366 const byAnon = deleteRemark(db, anon, { id: bobs.value.id });
367 expect(byAnon.ok).toBe(false);
368 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
369
370 expect(deleteRemark(db, bob, { id: bobs.value.id }).ok).toBe(true);
371 const again = deleteRemark(db, bob, { id: bobs.value.id });
372 expect(again.ok).toBe(false);
373 if (!again.ok) expect(again.error).toBe("not_found");
374
375 const mine = say(alice, p.id);
376 if (!mine.ok) throw new Error(mine.message);
377 expect(deleteRemark(db, root, { id: mine.value.id }).ok).toBe(true);
378 expect(listRemarks(db, anon, { proposalId: p.id })).toEqual({
379 ok: true,
380 value: [],
381 });
382 });
383
384 test("an accepted counter adds exactly one option, once, and leaves the rest alone", () => {
385 const p = made(alice);
386 const counter = say(bob, p.id, {
387 kind: "counter",
388 body: "Neither room works in winter.",
389 optionLabel: "The library",
390 });
391 if (!counter.ok) throw new Error(counter.message);
392 expect(counter.value.option_label).toBe("The library");
393 expect(counter.value.accepted_at).toBeNull();
394
395 // Whoever wrote the counter does not get to take it themselves.
396 const byBob = acceptCounter(db, bob, { id: counter.value.id });
397 expect(byBob.ok).toBe(false);
398 if (!byBob.ok) expect(byBob.error).toBe("forbidden");
399 const byAnon = acceptCounter(db, anon, { id: counter.value.id });
400 expect(byAnon.ok).toBe(false);
401 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
402
403 const taken = acceptCounter(db, alice, { id: counter.value.id });
404 expect(taken.ok && taken.value.option.label).toBe("The library");
405 expect(taken.ok && taken.value.remark.accepted_at).not.toBeNull();
406 const after = getProposal(db, anon, { id: p.id });
407 expect(after.ok && after.value.options.map((o) => o.label)).toEqual([
408 "The kitchen",
409 "The garden",
410 "The library",
411 ]);
412 expect(after.ok && after.value.options.map((o) => o.position)).toEqual([
413 0, 1, 2,
414 ]);
415 // Nothing else about the proposal moved: the ballots people cast stand.
416 expect(after.ok && after.value.state).toBe("open");
417 expect(after.ok && after.value.decided_option_id).toBeNull();
418
419 const twice = acceptCounter(db, alice, { id: counter.value.id });
420 expect(twice.ok).toBe(false);
421 if (!twice.ok) expect(twice.error).toBe("invalid");
422 expect(
423 getProposal(db, anon, { id: p.id }).ok &&
424 (getProposal(db, anon, { id: p.id }) as { value: { options: unknown[] } })
425 .value.options.length,
426 ).toBe(3);
427
428 // A label already on the proposal is not a second option.
429 const same = say(bob, p.id, {
430 kind: "counter",
431 body: "The library again.",
432 optionLabel: "the LIBRARY",
433 });
434 if (!same.ok) throw new Error(same.message);
435 const clash = acceptCounter(db, alice, { id: same.value.id });
436 expect(clash.ok).toBe(false);
437 if (!clash.ok) expect(clash.error).toBe("invalid");
438
439 // A counter with nothing to add, and a remark that is not a counter.
440 const wordy = say(bob, p.id, { kind: "counter", body: "Not this way." });
441 if (!wordy.ok) throw new Error(wordy.message);
442 const nothing = acceptCounter(db, alice, { id: wordy.value.id });
443 expect(nothing.ok).toBe(false);
444 if (!nothing.ok) expect(nothing.error).toBe("invalid");
445 const plain = say(bob, p.id);
446 if (!plain.ok) throw new Error(plain.message);
447 const notCounter = acceptCounter(db, alice, { id: plain.value.id });
448 expect(notCounter.ok).toBe(false);
449 if (!notCounter.ok) expect(notCounter.error).toBe("invalid");
450
451 const nowhere = acceptCounter(db, alice, { id: 9999 });
452 expect(nowhere.ok).toBe(false);
453 if (!nowhere.ok) expect(nowhere.error).toBe("not_found");
454 });
455
456 test("a counter on a proposal that is no longer open cannot be accepted", () => {
457 const p = made(alice);
458 const counter = say(bob, p.id, {
459 kind: "counter",
460 body: "Ask again in March.",
461 optionLabel: "Wait until March",
462 });
463 if (!counter.ok) throw new Error(counter.message);
464 expect(withdrawProposal(db, alice, { id: p.id }).ok).toBe(true);
465
466 const late = acceptCounter(db, alice, { id: counter.value.id });
467 expect(late.ok).toBe(false);
468 if (!late.ok) {
469 expect(late.error).toBe("invalid");
470 expect(late.message).toContain("withdrawn");
471 }
472 // An admin is not a way around a proposal that has stopped taking options.
473 expect(acceptCounter(db, root, { id: counter.value.id }).ok).toBe(false);
474
475 // Nor is a decided one. Nothing votes yet, so the state is set by hand —
476 // the rule is the proposal's state and not how it got there.
477 const chosen = made(alice);
478 const late2 = say(bob, chosen.id, {
479 kind: "counter",
480 body: "Too late, but still.",
481 optionLabel: "Somewhere else",
482 });
483 if (!late2.ok) throw new Error(late2.message);
484 db.query("UPDATE proposal SET state = 'decided' WHERE id = ?1").run(
485 chosen.id,
486 );
487 const decided = acceptCounter(db, alice, { id: late2.value.id });
488 expect(decided.ok).toBe(false);
489 if (!decided.ok) {
490 expect(decided.error).toBe("invalid");
491 expect(decided.message).toContain("decided");
492 }
493 const still = getProposal(db, anon, { id: chosen.id });
494 expect(still.ok && still.value.options.length).toBe(2);
495 // And the thread on a decided proposal still takes remarks.
496 expect(say(bob, chosen.id, { body: "Glad that is settled." }).ok).toBe(true);
497 const after = getProposal(db, anon, { id: p.id });
498 expect(after.ok && after.value.options.length).toBe(2);
499 expect(
500 listRemarks(db, anon, { proposalId: p.id }).ok &&
501 counter.value.accepted_at,
502 ).toBeNull();
503 });
504
505 test("a proposal full of options takes no more from a counter", () => {
506 const p = made(alice, {
507 options: Array.from({ length: 8 }, (_, i) => `Option ${i}`),
508 });
509 const counter = say(bob, p.id, {
510 kind: "counter",
511 body: "One more.",
512 optionLabel: "Option 9",
513 });
514 if (!counter.ok) throw new Error(counter.message);
515 const full = acceptCounter(db, alice, { id: counter.value.id });
516 expect(full.ok).toBe(false);
517 if (!full.ok) expect(full.error).toBe("invalid");
518 expect(getProposal(db, anon, { id: p.id }).ok).toBe(true);
519 });
520
521 test("a remark goes when its proposal does", () => {
522 const p = made(alice);
523 expect(say(bob, p.id).ok).toBe(true);
524 db.query("DELETE FROM proposal WHERE id = ?1").run(p.id);
525 expect(
526 db
527 .query<
528 { n: number },
529 [number]
530 >("SELECT count(*) AS n FROM remark WHERE proposal_id = ?1")
531 .get(p.id)!.n,
532 ).toBe(0);
533 });
534
250535 const PUBLIC_HEALTH = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"];
251536
252537 test("health tells anyone how the app is and nobody else where it lives", () => {
src/actions.ts +227 −3
@@ -70,6 +70,27 @@ export interface FullProposal extends Proposal {
7070 readonly options: readonly Option[];
7171 }
7272
73 /**
74 * What someone can say about a proposal. A `comment` is a remark on it, a
75 * `question` asks its author something, and a `counter` argues for another
76 * option — naming it in `option_label` when it has one to name.
77 */
78 export type Kind = "comment" | "question" | "counter";
79 export const KINDS: readonly Kind[] = ["comment", "question", "counter"];
80
81 export interface Remark {
82 readonly id: number;
83 readonly proposal_id: number;
84 readonly kind: Kind;
85 readonly body: string;
86 readonly option_label: string | null;
87 /** When a counter's label was appended to the proposal, or null. */
88 readonly accepted_at: string | null;
89 readonly author_id: string;
90 readonly author_name: string;
91 readonly created_at: string;
92 }
93
7394 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
7495 export type Result<T> =
7596 | { ok: true; value: T }
@@ -240,6 +261,187 @@ export function withdrawProposal(
240261 return ok(withdrawn);
241262 }
242263
264 /**
265 * The argument on a proposal, oldest first. Anyone reads it, including a
266 * guest: what was said about a decision is part of the decision.
267 */
268 export function listRemarks(
269 db: Database,
270 _who: Who,
271 input: { proposalId: number },
272 ): Result<Remark[]> {
273 if (!Number.isInteger(input.proposalId))
274 return fail("invalid", "A proposal id is a whole number.");
275 if (!exists(db, input.proposalId))
276 return fail("not_found", "No such proposal.");
277 return ok(
278 db
279 .query<
280 Remark,
281 [number]
282 >("SELECT * FROM remark WHERE proposal_id = ?1 ORDER BY id")
283 .all(input.proposalId),
284 );
285 }
286
287 /**
288 * Say something about a proposal. A withdrawn or decided one still takes
289 * remarks: the record continues after the decision, and an argument about
290 * what was decided belongs where the decision is.
291 */
292 export function addRemark(
293 db: Database,
294 who: Who,
295 input: {
296 proposalId: number;
297 kind: string;
298 body: string;
299 optionLabel?: string | undefined;
300 },
301 ): Result<Remark> {
302 if (who.role === "anon")
303 return fail("signin_required", "Sign in to say something.");
304 if (!Number.isInteger(input.proposalId))
305 return fail("invalid", "A proposal id is a whole number.");
306 const kind = String(input.kind ?? "");
307 if (!KINDS.includes(kind as Kind))
308 return fail("invalid", `A remark is one of ${KINDS.join(", ")}.`);
309 const body = String(input.body ?? "").trim();
310 if (body.length === 0 || body.length > BODY_MAX)
311 return fail("invalid", `A remark is 1 to ${BODY_MAX} characters.`);
312 const label = String(input.optionLabel ?? "").trim();
313 // A label is what a counter is for; on anything else it would be stored
314 // and never looked at, which is worse than being told.
315 if (label !== "" && kind !== "counter")
316 return fail("invalid", "Only a counter names an option.");
317 if (label.length > LABEL_MAX)
318 return fail(
319 "invalid",
320 `An option label is at most ${LABEL_MAX} characters.`,
321 );
322 if (!exists(db, input.proposalId))
323 return fail("not_found", "No such proposal.");
324 const remark = db
325 .query<
326 Remark,
327 [number, string, string, string | null, string, string]
328 >("INSERT INTO remark (proposal_id, kind, body, option_label, author_id, author_name) VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING *")
329 .get(
330 input.proposalId,
331 kind,
332 body,
333 label === "" ? null : label,
334 who.id,
335 who.name,
336 )!;
337 emit("remark.added", remark);
338 return ok(remark);
339 }
340
341 /** Take a remark back: its author, or an admin. */
342 export function deleteRemark(
343 db: Database,
344 who: Who,
345 input: { id: number },
346 ): Result<{ id: number; proposal_id: number }> {
347 if (who.role === "anon")
348 return fail("signin_required", "Sign in to delete a remark.");
349 if (!Number.isInteger(input.id))
350 return fail("invalid", "A remark id is a whole number.");
351 const remark = db
352 .query<Remark, [number]>("SELECT * FROM remark WHERE id = ?1")
353 .get(input.id);
354 if (remark === null) return fail("not_found", "No such remark.");
355 if (!canDeleteRemark(who, remark))
356 return fail("forbidden", "Only the author or an admin can delete a remark.");
357 db.query("DELETE FROM remark WHERE id = ?1").run(remark.id);
358 const gone = { id: remark.id, proposal_id: remark.proposal_id };
359 emit("remark.removed", gone);
360 return ok(gone);
361 }
362
363 /**
364 * Take the option a counter asks for: append it to the proposal and stamp
365 * the remark. Only the proposal's author or an admin, only while it is
366 * open, and only once — and nothing else about the proposal moves, so every
367 * ballot already cast still means what it meant.
368 */
369 export function acceptCounter(
370 db: Database,
371 who: Who,
372 input: { id: number },
373 ): Result<{ remark: Remark; option: Option }> {
374 if (who.role === "anon")
375 return fail("signin_required", "Sign in to accept a counter.");
376 if (!Number.isInteger(input.id))
377 return fail("invalid", "A remark id is a whole number.");
378 const remark = db
379 .query<Remark, [number]>("SELECT * FROM remark WHERE id = ?1")
380 .get(input.id);
381 if (remark === null) return fail("not_found", "No such remark.");
382 const proposal = db
383 .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1")
384 .get(remark.proposal_id);
385 if (proposal === null) return fail("not_found", "No such proposal.");
386 if (!canAcceptCounter(who, proposal))
387 return fail(
388 "forbidden",
389 "Only the proposal's author or an admin can accept a counter.",
390 );
391 if (remark.kind !== "counter")
392 return fail("invalid", "Only a counter proposes an option.");
393 if (remark.option_label === null)
394 return fail("invalid", "This counter names no option to add.");
395 if (remark.accepted_at !== null)
396 return fail("invalid", "This counter has already been accepted.");
397 if (proposal.state !== "open")
398 return fail(
399 "invalid",
400 `This proposal is ${proposal.state}; only an open one takes another option.`,
401 );
402 const options = db
403 .query<
404 Option,
405 [number]
406 >("SELECT * FROM option WHERE proposal_id = ?1 ORDER BY position")
407 .all(proposal.id);
408 const label = remark.option_label;
409 if (options.some((o) => o.label.toLowerCase() === label.toLowerCase()))
410 return fail("invalid", "That option is already on this proposal.");
411 if (options.length >= OPTIONS_MAX)
412 return fail(
413 "invalid",
414 `A proposal has at most ${OPTIONS_MAX} options; this one is full.`,
415 );
416 // The option and the stamp are one thing: both rows or neither.
417 const done = db.transaction(() => {
418 const option = db
419 .query<
420 Option,
421 [number, string, number]
422 >("INSERT INTO option (proposal_id, label, position) VALUES (?1, ?2, ?3) RETURNING *")
423 .get(proposal.id, label, options.length)!;
424 const stamped = db
425 .query<
426 Remark,
427 [number]
428 >("UPDATE remark SET accepted_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *")
429 .get(remark.id)!;
430 db.query(
431 "UPDATE proposal SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1",
432 ).run(proposal.id);
433 return { remark: stamped, option };
434 })();
435 emit("remark.accepted", done);
436 return ok(done);
437 }
438
439 /** Whether a proposal with this id is there at all. */
440 const exists = (db: Database, id: number): boolean =>
441 db
442 .query<{ id: number }, [number]>("SELECT id FROM proposal WHERE id = ?1")
443 .get(id) !== null;
444
243445 /** What anyone may know about this process: nothing about anybody's data. */
244446 export interface Health {
245447 readonly ok: boolean;
@@ -327,6 +529,14 @@ function soundness(db: Database, now = Date.now()): string {
327529 return verdict;
328530 }
329531
532 /**
533 * Ownership, once: whoever wrote the row, or an admin. Every "only the
534 * author or an admin" in this file is this function, so a door that asks
535 * gets the same answer the action will give it.
536 */
537 const mine = (who: Who, row: { author_id: string }): boolean =>
538 who.role === "admin" || (who.role === "user" && who.id === row.author_id);
539
330540 /**
331541 * Whether `who` may withdraw `proposal` at all. The state is the action's
332542 * own check; the page and the terminal ask this and the state together, so
@@ -335,6 +545,20 @@ function soundness(db: Database, now = Date.now()): string {
335545 export const canWithdraw = (
336546 who: Who,
337547 proposal: Pick<Proposal, "author_id">,
338 ): boolean =>
339 who.role === "admin" ||
340 (who.role === "user" && who.id === proposal.author_id);
548 ): boolean => mine(who, proposal);
549
550 /** Whether `who` may delete `remark`: its author, or an admin. */
551 export const canDeleteRemark = (
552 who: Who,
553 remark: Pick<Remark, "author_id">,
554 ): boolean => mine(who, remark);
555
556 /**
557 * Whether `who` may accept a counter on `proposal` — the proposal's author
558 * or an admin. Whether this counter can be accepted is `acceptCounter`'s
559 * own check; the page asks both to decide whether to draw the button.
560 */
561 export const canAcceptCounter = (
562 who: Who,
563 proposal: Pick<Proposal, "author_id">,
564 ): boolean => mine(who, proposal);
src/db.ts +16
@@ -58,6 +58,22 @@ 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 argument, kept with what it argues about: a remark is a comment, a
62 // question, or a counter that names the option it wants instead
63 // (`option_label`, stamped `accepted_at` when the proposal's author takes
64 // it). It goes when its proposal goes.
65 `CREATE TABLE remark (
66 id INTEGER PRIMARY KEY,
67 proposal_id INTEGER NOT NULL REFERENCES proposal (id) ON DELETE CASCADE,
68 kind TEXT NOT NULL CHECK (kind IN ('comment', 'question', 'counter')),
69 body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
70 option_label TEXT CHECK (option_label IS NULL OR length(option_label) BETWEEN 1 AND 80),
71 accepted_at TEXT,
72 author_id TEXT NOT NULL,
73 author_name TEXT NOT NULL,
74 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
75 );
76 CREATE INDEX remark_proposal ON remark (proposal_id)`,
6177 ];
6278
6379 export function openDb(dir = dataDir): Database {
src/mcp.ts +48
@@ -8,12 +8,15 @@
88
99 import type { Database } from "bun:sqlite";
1010 import {
11 addRemark,
1112 getProposal,
1213 health,
1314 listProposals,
15 listRemarks,
1416 propose,
1517 withdrawProposal,
1618 BODY_MAX,
19 KINDS,
1720 LABEL_MAX,
1821 OPTIONS_MAX,
1922 OPTIONS_MIN,
@@ -123,6 +126,51 @@ export const tools: readonly Tool[] = [
123126 },
124127 run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }),
125128 },
129 {
130 name: "list_remarks",
131 description:
132 "The argument on one proposal — comments, questions and counters — oldest first. Anyone may read it.",
133 inputSchema: {
134 type: "object",
135 properties: {
136 proposal_id: { type: "integer", minimum: 1 },
137 },
138 required: ["proposal_id"],
139 },
140 run: (db, who, args) =>
141 listRemarks(db, who, { proposalId: Number(args.proposal_id) }),
142 },
143 {
144 name: "add_remark",
145 description:
146 "Say something on a proposal as the signed-in person: a comment on it, a question for its author, or a counter arguing for another option. A counter may name that option in option_label, and the proposal's author can then add it. A withdrawn or decided proposal still takes remarks. Anonymous callers cannot.",
147 inputSchema: {
148 type: "object",
149 properties: {
150 proposal_id: { type: "integer", minimum: 1 },
151 kind: { type: "string", enum: [...KINDS] },
152 body: { type: "string", minLength: 1, maxLength: BODY_MAX },
153 option_label: {
154 type: "string",
155 minLength: 1,
156 maxLength: LABEL_MAX,
157 description:
158 "The option this counter wants the proposal to offer instead. Counters only.",
159 },
160 },
161 required: ["proposal_id", "kind", "body"],
162 },
163 run: (db, who, args) =>
164 addRemark(db, who, {
165 proposalId: Number(args.proposal_id),
166 kind: String(args.kind ?? ""),
167 body: String(args.body ?? ""),
168 optionLabel:
169 args.option_label === undefined || args.option_label === null
170 ? undefined
171 : String(args.option_label),
172 }),
173 },
126174 {
127175 name: "health",
128176 description:
src/pages/about.ts +3 −1
@@ -24,6 +24,8 @@ interface RuleEntry {
2424 const WORKS: readonly string[] = [
2525 "Open a proposal: a title, a paragraph, two to eight options, and the rule that will decide it.",
2626 "Read every proposal and any one of them, signed in or not.",
27 "Say something on a proposal: a comment, a question for its author, or a counter arguing for another option — and delete your own again.",
28 "Accept a counter on a proposal you opened: the option it names is added to it, and every ballot already cast still stands.",
2729 "Withdraw a proposal you opened while it is open; an admin withdraws any.",
2830 "Watch the list stay current as other people write, without reloading.",
2931 ];
@@ -35,7 +37,7 @@ const WORKS: readonly string[] = [
3537 */
3638 const NOT_YET: readonly string[] = [
3739 "Voting. The counting in the quorum module is written and tested, but nothing casts a ballot yet, so nothing is decided yet either.",
38 "Deliberation. There is no way to ask a question or argue a point on a proposal before it decides.",
40 "Answers. A question on a proposal is asked in the open and answered in the open; nothing here tells its author that one is waiting.",
3941 "A roster. Who counts as a member of this group has no page and no list; a “named few” rule is the only way to say who counts.",
4042 "Bindings. A decision is a record here and does nothing elsewhere — no webhook, no issue, no file.",
4143 ];
src/pages/proposal.ts +108 −4
@@ -1,21 +1,50 @@
11 /**
22 * One proposal: its title, its paragraph, its options in order, who wrote
33 * it and where it stands — and a Withdraw form for whoever may withdraw it
4 * while it is open. A guest sees all of it and no forms.
4 * while it is open. Below it the argument: a composer for whoever is signed
5 * in, and then the thread, oldest first. A guest sees all of it and no forms.
6 *
7 * The composer sits above the thread on purpose. The thread is the live
8 * region, it grows at the bottom, and it is the last thing on the page — so
9 * a remark that arrives while someone is typing moves nothing they can see,
10 * and the box they are typing in is never the thing that gets replaced.
511 */
612
7 import { canWithdraw, type FullProposal } from "../actions.ts";
13 import {
14 canAcceptCounter,
15 canDeleteRemark,
16 canWithdraw,
17 KINDS,
18 BODY_MAX,
19 LABEL_MAX,
20 type FullProposal,
21 type Kind,
22 type Remark,
23 } from "../actions.ts";
824 import { escape } from "../http.ts";
925 import { describeRule } from "../quorum.ts";
1026 import type { Who } from "../who.ts";
1127 import { ago, layout, toast } from "./layout.ts";
1228
29 /** What each kind is called on the page, and what it is for in one line. */
30 const KIND_LABEL: Record<Kind, string> = {
31 comment: "Comment",
32 question: "Question",
33 counter: "Counter",
34 };
35 const KIND_HINT: Record<Kind, string> = {
36 comment: "something to say about this proposal",
37 question: "something to ask its author",
38 counter: "another option it should offer instead",
39 };
40
1341 export function proposalPage(input: {
1442 who: Who;
1543 proposal: FullProposal;
44 remarks: readonly Remark[];
1645 notice?: string | undefined;
1746 }): string {
18 const { who, proposal, notice } = input;
47 const { who, proposal, remarks, notice } = input;
1948 const withdraw =
2049 proposal.state === "open" && canWithdraw(who, proposal)
2150 ? `<form method="post" action="/proposals/${proposal.id}/withdraw">
@@ -23,7 +52,7 @@ export function proposalPage(input: {
2352 </form>`
2453 : "";
2554 return layout(
26 `<section id="proposal" data-live="proposal.opened proposal.withdrawn" class="card">
55 `<section id="proposal" data-live="proposal.opened proposal.withdrawn remark.accepted" class="card">
2756 <div class="head-row">
2857 <h2>${escape(proposal.title)}</h2>
2958 <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span>
@@ -39,12 +68,87 @@ export function proposalPage(input: {
3968 .join("")}</ol>
4069 <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div>
4170 </section>
71 <section class="card">${composer(who, proposal)}</section>
72 <section id="thread" data-live="remark.added remark.removed remark.accepted">
73 ${thread(who, proposal, remarks)}
74 </section>
4275 ${toast(notice)}`,
4376 who,
4477 `/p/${proposal.id}`,
4578 );
4679 }
4780
81 /** Where a remark is written. A guest is told how to get one instead. */
82 const composer = (who: Who, proposal: FullProposal): string =>
83 who.role === "anon"
84 ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=${escape(encodeURIComponent(`/p/${proposal.id}`))}">Sign in</a> to comment, ask a question or counter with another option.</p>`
85 : `<form class="stack composer remark-form" method="post" action="/proposals/${proposal.id}/remarks">
86 <label class="field">
87 <span>Say something</span>
88 <textarea class="input" name="body" rows="3" placeholder="What you think, what you want to know, or the option you would rather have" required maxlength="${BODY_MAX}"></textarea>
89 </label>
90 <label class="field">
91 <span>As a</span>
92 <select class="input" name="kind">${KINDS.map(
93 (kind) =>
94 `<option value="${kind}"${kind === "comment" ? " selected" : ""}>${KIND_LABEL[kind]} — ${KIND_HINT[kind]}</option>`,
95 ).join("")}</select>
96 </label>
97 <label class="field counter-field">
98 <span>The option you want instead, for a counter only</span>
99 <input class="input" name="option_label" autocomplete="off" maxlength="${LABEL_MAX}" placeholder="Neither — ask again in March">
100 <span class="muted small">Left empty a counter is only an argument. Filled in, the proposal's author can add it to the options with one press; every ballot already cast still stands.</span>
101 </label>
102 <div class="row"><button class="button">Say it</button></div>
103 </form>`;
104
105 /** The argument, oldest first. */
106 const thread = (
107 who: Who,
108 proposal: FullProposal,
109 remarks: readonly Remark[],
110 ): string =>
111 remarks.length === 0
112 ? `<p class="muted small">Nothing said yet.</p>`
113 : `<p class="muted small">${remarks.length} remark${remarks.length === 1 ? "" : "s"}, oldest first.</p>
114 <ol class="list thread">${remarks.map((remark) => item(who, proposal, remark)).join("")}</ol>`;
115
116 const item = (who: Who, proposal: FullProposal, remark: Remark): string => {
117 const accept =
118 remark.kind === "counter" &&
119 remark.option_label !== null &&
120 remark.accepted_at === null &&
121 proposal.state === "open" &&
122 canAcceptCounter(who, proposal)
123 ? `<form method="post" action="/remarks/${remark.id}/accept">
124 <input type="hidden" name="proposal" value="${proposal.id}">
125 <button class="button button-quiet">Accept as an option</button>
126 </form>`
127 : "";
128 const remove = canDeleteRemark(who, remark)
129 ? `<form method="post" action="/remarks/${remark.id}/delete">
130 <input type="hidden" name="proposal" value="${proposal.id}">
131 <button class="button button-quiet">Delete</button>
132 </form>`
133 : "";
134 return `<li class="remark" data-kind="${escape(remark.kind)}">
135 <p class="muted small"><span class="pill" data-kind="${escape(remark.kind)}">${KIND_LABEL[remark.kind] ?? escape(remark.kind)}</span> ${escape(remark.author_name)} · <time datetime="${escape(remark.created_at)}">${ago(remark.created_at)}</time></p>
136 <p class="body">${escape(remark.body)}</p>
137 ${wanted(remark)}
138 <div class="row">${accept}${remove}</div>
139 </li>`;
140 };
141
142 /** The option a counter names, and whether it was taken. */
143 const wanted = (remark: Remark): string =>
144 remark.option_label === null
145 ? ""
146 : `<p class="small wanted">Instead: <b>${escape(remark.option_label)}</b>${
147 remark.accepted_at === null
148 ? ""
149 : ` · <span class="muted">added to the options <time datetime="${escape(remark.accepted_at)}">${ago(remark.accepted_at)}</time>; earlier ballots stand</span>`
150 }</p>`;
151
48152 /**
49153 * How this one is decided, in words, above the options. The rule was read
50154 * once in the action; a rule this app cannot read — a row from before the
src/plain.test.ts added +27
@@ -0,0 +1,27 @@
1 import { expect, test } from "bun:test";
2 import { oneLine, plain } from "./plain.ts";
3
4 test("text someone else wrote carries no instruction to a terminal", () => {
5 // A remark that tries to clear the screen, move the cursor and colour the
6 // rest of the session red comes out as the words it is made of.
7 const attack = "\x1b[2J\x1b[H\x1b[31mall clear\x07 \x00 \x9b1m";
8 const printed = plain(attack);
9 expect(printed).not.toContain("\x1b");
10 expect(printed).not.toContain("\x07");
11 expect(printed).not.toContain("\x00");
12 expect(printed).not.toContain("\x9b");
13 expect(printed).toContain("all clear");
14 // Every control character becomes a space, so the words stay apart.
15 expect(plain("a\x1bb")).toBe("a b");
16
17 // A paragraph is still a paragraph: the terminal wraps those itself.
18 expect(plain("one\ntwo\r\nthree")).toBe("one\ntwo\r\nthree");
19 // On one line it is one line, whatever it was.
20 expect(oneLine("one\ntwo")).toBe("one two");
21 // What is left of an escape sequence is the letters in it, printed.
22 expect(oneLine("one\x1b[31m\ntwo")).toBe("one [31m two");
23 // Ordinary text is untouched, accents and all.
24 expect(plain("Räume — “the garden”, 2 × 3")).toBe(
25 "Räume — “the garden”, 2 × 3",
26 );
27 });
src/plain.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * Text someone else wrote, printed on a terminal that reads escape
3 * sequences as instructions. `escape` in `http.ts` is this for HTML; this is
4 * it for a pty: every C0 and C1 control character becomes a space, so the
5 * only thing that moves the cursor in `src/tty.ts` is `src/tty.ts`.
6 *
7 * Newline and carriage return survive: the terminal wraps paragraphs itself
8 * and they are the shape of the text, not a command.
9 */
10
11 const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
12
13 export const plain = (text: string): string => text.replace(CONTROL, " ");
14
15 /** The same, on one line: a title in a list is a line and not three. */
16 export const oneLine = (text: string): string =>
17 plain(text).replace(/[\r\n]+/g, " ");
src/routes.ts +92 −2
@@ -6,9 +6,13 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 acceptCounter,
10 addRemark,
11 deleteRemark,
912 getProposal,
1013 health,
1114 listProposals,
15 listRemarks,
1216 propose,
1317 withdrawProposal,
1418 type Failure,
@@ -45,7 +49,7 @@ export interface Context {
4549 }
4650
4751 export interface Route {
48 readonly method: "GET" | "POST";
52 readonly method: "GET" | "POST" | "DELETE";
4953 readonly path: string;
5054 readonly handle: (ctx: Context) => Response | Promise<Response>;
5155 }
@@ -121,8 +125,14 @@ export const routes: readonly Route[] = [
121125 const found = getProposal(c.db, c.who, { id: Number(c.params.id) });
122126 // Anything that is not a proposal — a bad id or a gone one — is a 404.
123127 if (!found.ok) return html(missing(found.message, c.who), 404, clear);
128 const said = listRemarks(c.db, c.who, { proposalId: found.value.id });
124129 return html(
125 proposalPage({ who: c.who, proposal: found.value, notice }),
130 proposalPage({
131 who: c.who,
132 proposal: found.value,
133 remarks: said.ok ? said.value : [],
134 notice,
135 }),
126136 200,
127137 clear,
128138 );
@@ -159,6 +169,50 @@ export const routes: readonly Route[] = [
159169 pathOf(c.params.id ?? ""),
160170 ),
161171 },
172 // The thread. A remark form comes back to the proposal it was written on:
173 // the page puts that id in the form, and `pathOf` makes a path out of a
174 // whole number or out of nothing at all.
175 {
176 method: "POST",
177 path: "/proposals/:id/remarks",
178 handle: async (c) => {
179 const form = await readForm(c.request);
180 if (form === undefined) return text("too large", 413);
181 return done(
182 addRemark(c.db, c.who, {
183 proposalId: Number(c.params.id),
184 kind: String(form.get("kind") ?? ""),
185 body: String(form.get("body") ?? ""),
186 optionLabel: String(form.get("option_label") ?? ""),
187 }),
188 pathOf(c.params.id ?? ""),
189 );
190 },
191 },
192 {
193 method: "POST",
194 path: "/remarks/:id/delete",
195 handle: async (c) => {
196 const form = await readForm(c.request);
197 if (form === undefined) return text("too large", 413);
198 return done(
199 deleteRemark(c.db, c.who, { id: Number(c.params.id) }),
200 pathOf(String(form.get("proposal") ?? "")),
201 );
202 },
203 },
204 {
205 method: "POST",
206 path: "/remarks/:id/accept",
207 handle: async (c) => {
208 const form = await readForm(c.request);
209 if (form === undefined) return text("too large", 413);
210 return done(
211 acceptCounter(c.db, c.who, { id: Number(c.params.id) }),
212 pathOf(String(form.get("proposal") ?? "")),
213 );
214 },
215 },
162216
163217 // The JSON API: the same actions, the same identity, plain status codes.
164218 {
@@ -202,6 +256,42 @@ export const routes: readonly Route[] = [
202256 handle: (c) =>
203257 answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })),
204258 },
259 {
260 method: "GET",
261 path: "/api/proposals/:id/remarks",
262 handle: (c) =>
263 answer(listRemarks(c.db, c.who, { proposalId: Number(c.params.id) })),
264 },
265 {
266 method: "POST",
267 path: "/api/proposals/:id/remarks",
268 handle: async (c) => {
269 const body = await readJson(c.request);
270 if (body === undefined) return json({ error: "too_large" }, 413);
271 const input = fields(body);
272 return answer(
273 addRemark(c.db, c.who, {
274 proposalId: Number(c.params.id),
275 kind: String(input.kind ?? ""),
276 body: String(input.body ?? ""),
277 optionLabel: given(input.optionLabel ?? input.option_label),
278 }),
279 201,
280 );
281 },
282 },
283 {
284 method: "DELETE",
285 path: "/api/remarks/:id",
286 handle: (c) =>
287 answer(deleteRemark(c.db, c.who, { id: Number(c.params.id) })),
288 },
289 {
290 method: "POST",
291 path: "/api/remarks/:id/accept",
292 handle: (c) =>
293 answer(acceptCounter(c.db, c.who, { id: Number(c.params.id) })),
294 },
205295
206296 // What a probe asks, forever: 200 while the app is sound, 503 when it is
207297 // not, and never a word about anybody's data.
src/server.test.ts +254 −2
@@ -136,9 +136,12 @@ test("/about says what this is, from the modules that own the facts", async () =
136136 ? "off in this environment"
137137 : escape(process.env.FORGE_APP_SSH),
138138 );
139 // Honest about what is not built: voting, deliberation, bindings.
140 for (const missing of ["Voting.", "Deliberation.", "Bindings."])
139 // Honest about what is not built: voting, notifying, bindings — and
140 // deliberation has landed, so it is no longer on that list.
141 for (const missing of ["Voting.", "Answers.", "Bindings."])
141142 expect(page).toContain(missing);
143 expect(page).toContain("Say something on a proposal");
144 expect(page).not.toContain("Deliberation.");
142145 expect(page).toContain("reading as a guest");
143146 expect(page).not.toContain("Signed in as");
144147
@@ -453,6 +456,8 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
453456 "get_proposal",
454457 "propose",
455458 "withdraw_proposal",
459 "list_remarks",
460 "add_remark",
456461 "health",
457462 ]);
458463 const call = (name: string, args: unknown, who = {}) =>
@@ -492,6 +497,253 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
492497 expect(notified.status).toBe(202);
493498 });
494499
500 /** A remark over the API, for the tests that need a thread to look at. */
501 const remark = async (
502 who: Record<string, string>,
503 id: number,
504 body: unknown,
505 ) => {
506 const made = await api(
507 `/api/proposals/${id}/remarks`,
508 { method: "POST", ...jsonBody(body) },
509 who,
510 );
511 expect(made.status).toBe(201);
512 return (await made.json()) as { id: number; kind: string };
513 };
514
515 test("the thread reads for a guest, writes for a person, and escapes what it shows", async () => {
516 const p = await open(alice, { title: "Where to eat" });
517 const guest = await (await fetch(`${base}/p/${p.id}`)).text();
518 expect(guest).toContain("Nothing said yet.");
519 expect(guest).not.toContain('action="/proposals/' + p.id + '/remarks"');
520 expect(guest).toContain("Sign in");
521
522 // A guest cannot write, at either door.
523 const form = new FormData();
524 form.set("kind", "comment");
525 form.set("body", "Not mine to say.");
526 expect(
527 (
528 await api(`/proposals/${p.id}/remarks`, {
529 method: "POST",
530 body: form,
531 redirect: "manual",
532 })
533 ).headers.get("set-cookie"),
534 ).toContain("notice=");
535 expect(
536 (
537 await api(`/api/proposals/${p.id}/remarks`, {
538 method: "POST",
539 ...jsonBody({ kind: "comment", body: "Nor here." }),
540 })
541 ).status,
542 ).toBe(401);
543 expect(
544 ((await (await fetch(`${base}/api/proposals/${p.id}/remarks`)).json()) as
545 unknown[]).length,
546 ).toBe(0);
547
548 // A person writes, over the form, and lands back on the proposal.
549 const said = new FormData();
550 said.set("kind", "question");
551 said.set("body", "Is <script>alert(1)</script> a place?");
552 const posted = await api(
553 `/proposals/${p.id}/remarks`,
554 { method: "POST", body: said, redirect: "manual" },
555 bob,
556 );
557 expect(posted.status).toBe(303);
558 expect(posted.headers.get("location")).toBe(`/p/${p.id}`);
559 expect(posted.headers.get("set-cookie")).toBeNull();
560
561 const page = await (await fetch(`${base}/p/${p.id}`)).text();
562 expect(page).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
563 expect(page).not.toContain("<script>alert(1)");
564 expect(page).toContain("Question");
565 expect(page).toContain("bob ·");
566 // The thread is its own live region, and it comes after the composer: a
567 // remark arriving grows the bottom of the page and moves nothing above it,
568 // least of all the box someone is typing in.
569 expect(page).toContain(
570 '<section id="thread" data-live="remark.added remark.removed remark.accepted">',
571 );
572 const writing = await (await api(`/p/${p.id}`, {}, bob)).text();
573 expect(writing).toContain(`action="/proposals/${p.id}/remarks"`);
574 expect(writing.indexOf('id="thread"')).toBeGreaterThan(
575 writing.indexOf(`action="/proposals/${p.id}/remarks"`),
576 );
577
578 // A kind that is not a kind, and a body past two thousand characters.
579 for (const bad of [
580 { kind: "shout", body: "Hey" },
581 { kind: "comment", body: "x".repeat(2001) },
582 ]) {
583 const refused = await api(
584 `/api/proposals/${p.id}/remarks`,
585 { method: "POST", ...jsonBody(bad) },
586 bob,
587 );
588 expect(refused.status, JSON.stringify(bad).slice(0, 30)).toBe(422);
589 }
590 expect(
591 (
592 await api(
593 "/api/proposals/9999/remarks",
594 { method: "POST", ...jsonBody({ kind: "comment", body: "Nowhere" }) },
595 bob,
596 )
597 ).status,
598 ).toBe(404);
599 expect((await fetch(base + "/api/proposals/9999/remarks")).status).toBe(404);
600 });
601
602 test("the author deletes a remark, an admin deletes any, at both doors", async () => {
603 const p = await open(alice, { title: "What to delete" });
604 const mine = await remark(bob, p.id, {
605 kind: "comment",
606 body: "Said in haste.",
607 });
608 expect(
609 (await api(`/api/remarks/${mine.id}`, { method: "DELETE" }, alice)).status,
610 ).toBe(403);
611 expect(
612 (await api(`/api/remarks/${mine.id}`, { method: "DELETE" })).status,
613 ).toBe(401);
614 expect(
615 (await api(`/api/remarks/${mine.id}`, { method: "DELETE" }, bob)).status,
616 ).toBe(200);
617 expect(
618 (await api(`/api/remarks/${mine.id}`, { method: "DELETE" }, bob)).status,
619 ).toBe(404);
620
621 // And over the form the page draws, which an admin sees on anybody's.
622 const theirs = await remark(bob, p.id, {
623 kind: "comment",
624 body: "Said again.",
625 });
626 const seen = await (await api(`/p/${p.id}`, {}, root)).text();
627 expect(seen).toContain(`action="/remarks/${theirs.id}/delete"`);
628 expect(await (await api(`/p/${p.id}`, {}, alice)).text()).not.toContain(
629 `action="/remarks/${theirs.id}/delete"`,
630 );
631 const form = new FormData();
632 form.set("proposal", String(p.id));
633 const gone = await api(
634 `/remarks/${theirs.id}/delete`,
635 { method: "POST", body: form, redirect: "manual" },
636 root,
637 );
638 expect(gone.headers.get("location")).toBe(`/p/${p.id}`);
639 expect(gone.headers.get("set-cookie")).toBeNull();
640 expect(await (await fetch(`${base}/p/${p.id}`)).text()).not.toContain(
641 "Said again.",
642 );
643 });
644
645 test("accepting a counter adds one option and says the ballots stand", async () => {
646 const p = await open(alice, { title: "Where to meet in winter" });
647 const counter = await remark(bob, p.id, {
648 kind: "counter",
649 body: "Neither room is warm.",
650 optionLabel: "The library",
651 });
652 expect(counter.kind).toBe("counter");
653
654 // Only the proposal's author or an admin is offered the button.
655 const asBob = await (await api(`/p/${p.id}`, {}, bob)).text();
656 expect(asBob).not.toContain(`action="/remarks/${counter.id}/accept"`);
657 expect(asBob).toContain("Instead: <b>The library</b>");
658 const asAlice = await (await api(`/p/${p.id}`, {}, alice)).text();
659 expect(asAlice).toContain(`action="/remarks/${counter.id}/accept"`);
660
661 expect(
662 (await api(`/api/remarks/${counter.id}/accept`, { method: "POST" }, bob))
663 .status,
664 ).toBe(403);
665 const form = new FormData();
666 form.set("proposal", String(p.id));
667 const taken = await api(
668 `/remarks/${counter.id}/accept`,
669 { method: "POST", body: form, redirect: "manual" },
670 alice,
671 );
672 expect(taken.headers.get("location")).toBe(`/p/${p.id}`);
673 expect(taken.headers.get("set-cookie")).toBeNull();
674
675 const page = await (await fetch(`${base}/p/${p.id}`)).text();
676 expect(page).toContain("<li>The library</li>");
677 expect(page).toContain("earlier ballots stand");
678 expect(page).not.toContain(`action="/remarks/${counter.id}/accept"`);
679 const one = (await (await fetch(`${base}/api/proposals/${p.id}`)).json()) as {
680 options: { label: string }[];
681 };
682 expect(one.options.map((o) => o.label)).toEqual([
683 "The kitchen",
684 "The garden",
685 "The library",
686 ]);
687 // Twice is not twice: the second press changes nothing.
688 expect(
689 (await api(`/api/remarks/${counter.id}/accept`, { method: "POST" }, alice))
690 .status,
691 ).toBe(422);
692 expect(
693 (
694 (await (await fetch(`${base}/api/proposals/${p.id}`)).json()) as {
695 options: unknown[];
696 }
697 ).options.length,
698 ).toBe(3);
699 });
700
701 test("the thread is on the event stream and the MCP tools speak it too", async () => {
702 const talker = as("u7", "talker");
703 const p = await open(talker, { title: "Something to discuss" });
704 const stream = await fetch(base + "/events");
705 const reader = stream.body!.getReader();
706 const seen = async (type: string): Promise<void> => {
707 let buffer = "";
708 for (;;) {
709 const { value } = await reader.read();
710 buffer += new TextDecoder().decode(value);
711 if (buffer.includes(type)) return;
712 }
713 };
714 const call = (name: string, args: unknown, who = {}) =>
715 rpc(
716 {
717 jsonrpc: "2.0",
718 id: 7,
719 method: "tools/call",
720 params: { name, arguments: args },
721 },
722 who,
723 );
724 const added = await call(
725 "add_remark",
726 { proposal_id: p.id, kind: "comment", body: "Through MCP." },
727 talker,
728 );
729 expect(added.result.isError).toBeUndefined();
730 await seen("remark.added");
731 const listed = JSON.parse(
732 (await call("list_remarks", { proposal_id: p.id })).result.content[0].text,
733 ) as { body: string }[];
734 expect(listed.map((r) => r.body)).toEqual(["Through MCP."]);
735 // A bare caller reads and cannot write.
736 expect(
737 (await call("add_remark", { proposal_id: p.id, kind: "comment", body: "No" }))
738 .result.isError,
739 ).toBe(true);
740
741 const made = JSON.parse(added.result.content[0].text) as { id: number };
742 await api(`/api/remarks/${made.id}`, { method: "DELETE" }, talker);
743 await seen("remark.removed");
744 await reader.cancel();
745 });
746
495747 test("/api/health answers a probe, and only an admin sees the environment", async () => {
496748 const PUBLIC = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"];
497749 for (const who of [{}, alice]) {
src/tty.ts +189 −24
@@ -2,21 +2,32 @@
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 — with the argument on it underneath — n
6 * proposes, c says something, w withdraws, Esc goes back and q leaves.
7 * Plain ANSI, no dependencies.
8 *
9 * Nothing anyone wrote reaches this screen as a control character: what
10 * someone else typed goes through `plain.ts` on the way out, so the only
11 * escape sequences a terminal here sees are the ones in this file.
712 */
813
914 import {
15 addRemark,
1016 canWithdraw,
1117 getProposal,
1218 listProposals,
19 listRemarks,
1320 propose,
1421 withdrawProposal,
22 KINDS,
1523 OPTIONS_MAX,
1624 type FullProposal,
25 type Kind,
1726 type ProposalSummary,
27 type Remark,
1828 } from "./actions.ts";
1929 import { openDb } from "./db.ts";
30 import { oneLine, plain } from "./plain.ts";
2031 import { ssh, title, where } from "./env.ts";
2132 import { describeRule, RULE_FORMS } from "./quorum.ts";
2233 import { whoAtTty } from "./who.ts";
@@ -40,9 +51,19 @@ interface Draft {
4051 typing: string;
4152 }
4253
54 /** A remark being written on the proposal that is open. */
55 interface RemarkDraft {
56 step: "kind" | "body" | "label";
57 proposalId: number;
58 kind: Kind;
59 body: string;
60 typing: string;
61 }
62
4363 let cursor = 0;
4464 let open: number | undefined;
4565 let draft: Draft | undefined;
66 let remarkDraft: RemarkDraft | undefined;
4667 let said = "";
4768
4869 const proposals = (): ProposalSummary[] => {
@@ -55,23 +76,30 @@ const one = (id: number): FullProposal | undefined => {
5576 return found.ok ? found.value : undefined;
5677 };
5778
58 /** Long text, broken at spaces so a narrow terminal still reads. */
79 /**
80 * Long text, broken at spaces so a narrow terminal still reads — and
81 * stripped of the control characters that would otherwise let what someone
82 * wrote drive the cursor (`plain.ts`). Every line of somebody else's text
83 * printed here comes through this.
84 */
5985 const wrap = (text: string, width = 72): string[] =>
60 text.split(/\r?\n/).flatMap((paragraph) => {
61 const lines: string[] = [];
62 let line = "";
63 for (const word of paragraph.split(/\s+/)) {
64 if (word === "") continue;
65 if (line === "") line = word;
66 else if (line.length + 1 + word.length <= width) line += ` ${word}`;
67 else {
68 lines.push(line);
69 line = word;
86 plain(text)
87 .split(/\r?\n/)
88 .flatMap((paragraph) => {
89 const lines: string[] = [];
90 let line = "";
91 for (const word of paragraph.split(/\s+/)) {
92 if (word === "") continue;
93 if (line === "") line = word;
94 else if (line.length + 1 + word.length <= width) line += ` ${word}`;
95 else {
96 lines.push(line);
97 line = word;
98 }
7099 }
71 }
72 lines.push(line);
73 return lines;
74 });
100 lines.push(line);
101 return lines;
102 });
75103
76104 function draw(): void {
77105 const lines = [
@@ -80,6 +108,7 @@ function draw(): void {
80108 "",
81109 ];
82110 if (draft !== undefined) lines.push(...composer(draft));
111 else if (remarkDraft !== undefined) lines.push(...remarkComposer(remarkDraft));
83112 else if (open !== undefined) lines.push(...detail(open));
84113 else lines.push(...list());
85114 lines.push("");
@@ -98,8 +127,9 @@ const list = (): string[] => {
98127 ),
99128 ];
100129 return rows.map((p, i) => {
101 const line = `${p.title} ${dim(`${p.author_name} · ${p.option_count} options · ${p.created_at.slice(0, 16).replace("T", " ")}`)}`;
102 return `${i === cursor ? "▸" : " "} ${i === cursor ? bold(p.title) + line.slice(p.title.length) : line}`;
130 const title = oneLine(p.title);
131 const line = `${title} ${dim(`${oneLine(p.author_name)} · ${p.option_count} options · ${p.created_at.slice(0, 16).replace("T", " ")}`)}`;
132 return `${i === cursor ? "▸" : " "} ${i === cursor ? bold(title) + line.slice(title.length) : line}`;
103133 });
104134 };
105135
@@ -107,23 +137,59 @@ const detail = (id: number): string[] => {
107137 const proposal = one(id);
108138 if (proposal === undefined) return [dim("That proposal is gone.")];
109139 return [
110 bold(proposal.title),
140 bold(oneLine(proposal.title)),
111141 dim(
112 `${proposal.author_name} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`,
142 `${oneLine(proposal.author_name)} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`,
113143 ),
114144 "",
115145 ...wrap(proposal.body),
116146 "",
117147 dim(
118148 proposal.rule_parsed === undefined
119 ? `A rule this app cannot read: ${proposal.rule}`
149 ? `A rule this app cannot read: ${oneLine(proposal.rule)}`
120150 : describeRule(proposal.rule_parsed),
121151 ),
122152 "",
123 ...proposal.options.map((o) => ` ${o.position + 1}. ${o.label}`),
153 ...proposal.options.map((o) => ` ${o.position + 1}. ${oneLine(o.label)}`),
154 "",
155 ...thread(id),
124156 ];
125157 };
126158
159 /** The argument under the proposal, oldest first, as anyone reads it. */
160 const thread = (id: number): string[] => {
161 const said = listRemarks(db, me, { proposalId: id });
162 const remarks = said.ok ? said.value : [];
163 if (remarks.length === 0)
164 return [
165 dim(
166 me.role === "anon"
167 ? "Nothing said yet."
168 : "Nothing said yet. Press c to say something.",
169 ),
170 ];
171 return [
172 dim(`${remarks.length} remark${remarks.length === 1 ? "" : "s"}:`),
173 ...remarks.flatMap(line),
174 ];
175 };
176
177 /** One remark: who said what kind of thing, the text, and what it wants. */
178 const line = (remark: Remark): string[] => [
179 "",
180 dim(
181 ` [${remark.kind}] ${oneLine(remark.author_name)} · ${remark.created_at.slice(0, 16).replace("T", " ")}`,
182 ),
183 ...wrap(remark.body, 68).map((l) => ` ${l}`),
184 ...(remark.option_label === null
185 ? []
186 : [
187 ` ${dim("instead:")} ${oneLine(remark.option_label)}${
188 remark.accepted_at === null ? "" : dim(" (accepted)")
189 }`,
190 ]),
191 ];
192
127193 const composer = (d: Draft): string[] => {
128194 const cell = `${esc}7m ${esc}0m`;
129195 const lines = [bold("A new proposal"), ""];
@@ -156,9 +222,45 @@ const composer = (d: Draft): string[] => {
156222 return lines;
157223 };
158224
225 /**
226 * A remark being typed: the kind first — one key, so nobody types the word
227 * — then the text, then the option a counter wants instead.
228 */
229 const remarkComposer = (d: RemarkDraft): string[] => {
230 const cell = `${esc}7m ${esc}0m`;
231 const lines = [bold("Saying something"), ""];
232 if (d.step === "kind") {
233 lines.push(dim("What kind of thing is it?"));
234 KINDS.forEach((kind, i) =>
235 lines.push(` ${i + 1}. ${kind}${kind === d.kind ? dim(" ←") : ""}`),
236 );
237 lines.push("");
238 lines.push(dim("1, 2 or 3 — or Enter for a comment."));
239 return lines;
240 }
241 lines.push(dim(`Kind: ${d.kind}`));
242 lines.push(
243 d.step === "body"
244 ? `Remark: ${d.typing}${cell}`
245 : dim(`Remark: ${plain(d.body)}`),
246 );
247 if (d.step === "label") {
248 lines.push("");
249 lines.push(
250 dim("The option you want instead. Enter alone to name none."),
251 );
252 lines.push(`Instead: ${d.typing}${cell}`);
253 }
254 return lines;
255 };
256
159257 const keys = (): string => {
160258 if (draft !== undefined) return "Enter accepts · Esc cancels";
161 if (open !== undefined) return "Esc back · w withdraw · q quit";
259 if (remarkDraft !== undefined) return "Enter accepts · Esc cancels";
260 if (open !== undefined)
261 return me.role === "anon"
262 ? "Esc back · q quit · sign in through the forge to say something"
263 : "Esc back · c say something · w withdraw · q quit";
162264 return me.role === "anon"
163265 ? "j/k move · Enter open · q quit · sign in through the forge to propose"
164266 : "j/k move · Enter open · n propose · w withdraw · q quit";
@@ -186,6 +288,27 @@ function submit(d: Draft): void {
186288 }
187289 }
188290
291 /** The remark, finished: the same action the page and the API call. */
292 function say(d: RemarkDraft, optionLabel: string): void {
293 const made = addRemark(db, me, {
294 proposalId: d.proposalId,
295 kind: d.kind,
296 body: d.body,
297 optionLabel,
298 });
299 if (made.ok) {
300 said = `Said, as a ${made.value.kind}.`;
301 remarkDraft = undefined;
302 open = d.proposalId;
303 } else {
304 // Keep what was typed: the message says what to change.
305 said = made.message;
306 d.step = "body";
307 d.typing = d.body;
308 d.body = "";
309 }
310 }
311
189312 function withdraw(id: number | undefined): void {
190313 if (id === undefined) {
191314 said = "Nothing to withdraw.";
@@ -244,6 +367,35 @@ process.stdin.on("data", (key: string) => {
244367 draw();
245368 return;
246369 }
370 if (remarkDraft !== undefined) {
371 const d = remarkDraft;
372 if (key === "\x1b") {
373 remarkDraft = undefined;
374 said = "Cancelled.";
375 } else if (key === "\x03") {
376 remarkDraft = undefined;
377 } else if (d.step === "kind") {
378 // One key for the kind: the list is numbered on the screen above.
379 const picked = KINDS[Number(key) - 1];
380 if (key === "\r" || picked !== undefined) {
381 if (picked !== undefined) d.kind = picked;
382 d.step = "body";
383 d.typing = "";
384 }
385 } else if (key === "\r") {
386 if (d.step === "body") {
387 d.body = d.typing.trim();
388 // Only a counter names an option; anything else is said and done.
389 if (d.kind === "counter") {
390 d.step = "label";
391 d.typing = "";
392 } else say(d, "");
393 } else say(d, d.typing.trim());
394 } else if (key === "\x7f") d.typing = d.typing.slice(0, -1);
395 else if (key >= " ") d.typing += key;
396 draw();
397 return;
398 }
247399 if (key === "q" || key === "\x03") {
248400 out(`${esc}2J${esc}H`);
249401 process.exit(0);
@@ -273,6 +425,19 @@ process.stdin.on("data", (key: string) => {
273425 typing: "",
274426 };
275427 }
428 if (key === "c") {
429 if (me.role === "anon")
430 said = "Sign in through the forge to say something.";
431 else if (under === undefined) said = "Nothing to say it about.";
432 else
433 remarkDraft = {
434 step: "kind",
435 proposalId: under,
436 kind: "comment",
437 body: "",
438 typing: "",
439 };
440 }
276441 if (key === "w") withdraw(under);
277442 draw();
278443 });
static/app.css +46
@@ -218,6 +218,52 @@ code,
218218 font-weight: 650;
219219 color: var(--accent);
220220 }
221 /* The argument. The thread is the last thing on the page and grows at the
222 bottom, so a remark arriving over /events moves nothing above it. */
223 .thread {
224 display: grid;
225 gap: var(--s3);
226 margin: var(--s3) 0 0;
227 padding: 0;
228 }
229 .remark {
230 background: var(--surface);
231 border: 1px solid var(--line);
232 border-left: 3px solid var(--line);
233 border-radius: var(--radius);
234 padding: var(--s3);
235 }
236 .remark[data-kind="question"] {
237 border-left-color: var(--accent);
238 }
239 .remark[data-kind="counter"] {
240 border-left-color: var(--danger);
241 }
242 .remark .body {
243 margin: var(--s2) 0 0;
244 }
245 .remark .row:empty {
246 display: none;
247 }
248 .pill[data-kind="question"] {
249 color: var(--accent);
250 border-color: currentColor;
251 }
252 .pill[data-kind="counter"] {
253 color: var(--danger);
254 border-color: currentColor;
255 }
256 .wanted {
257 margin-top: var(--s2);
258 overflow-wrap: anywhere;
259 }
260 /* The option a counter wants matters only to a counter. Shown plainly
261 wherever :has is not understood, so the field is never unreachable. */
262 .remark-form:has(select[name="kind"] option:checked:not([value="counter"]))
263 .counter-field {
264 display: none;
265 }
266
221267 /* Nothing to show yet: the same card, drawn as an outline, saying what this
222268 app is rather than that it is empty. */
223269 .empty {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs