Public: anyone can read this; the crew can change it.
Deliberation: comments, questions and counter-proposals on a proposal #32
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/6-deliberation-comments-questions-and-coun → main 2 commits fork
13 files
changed +1369
−47
README.md +57 −11
| @@ -23,8 +23,9 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 23 | 23 | |
| 24 | 24 | | file | what it is | |
| 25 | 25 | | ---------------------------------- | ------------------------------------------------------------ | |
| 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 | | |
| 27 | 27 | | `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 | | |
| 28 | 29 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 29 | 30 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 30 | 31 | | `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: | ||
| 44 | 45 | |
| 45 | 46 | - **anon** — anyone. Reads. |
| 46 | 47 | - **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. | |
| 49 | 53 | |
| 50 | 54 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 51 | 55 | `/__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 | ||
| 64 | 68 | the options. A guest sees both pages and no forms. With nothing proposed yet, |
| 65 | 69 | `/` says in four sentences what a proposal is rather than that it is empty. |
| 66 | 70 | |
| 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 | ||
| 67 | 79 | `/about`, linked from the footer of every page, is the longer version: how a |
| 68 | 80 | proposal is decided, the three rules printed from `src/quorum.ts`, two plain |
| 69 | 81 | 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 | ||
| 79 | 91 | curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \ |
| 80 | 92 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}' |
| 81 | 93 | 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 | |
| 82 | 99 | curl https://<app>/api/health # 200 while sound, 503 when it is not |
| 83 | 100 | |
| 84 | 101 | |
| @@ -111,9 +128,34 @@ named:alice,bob,carol:2 2 of those three agree; nobody else counts | ||
| 111 | 128 | Anything else is refused with a sentence naming the three forms, and no rule |
| 112 | 129 | at all means `majority`. A proposal keeps the rule it was opened with. |
| 113 | 130 | |
| 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 | ||
| 114 | 156 | 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): | |
| 117 | 159 | |
| 118 | 160 | sh |
| 119 | 161 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
| @@ -126,7 +168,8 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp | ||
| 126 | 168 | |
| 127 | 169 | |
| 128 | 170 | 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, | |
| 130 | 173 | which is another process on the same file. Every event is a row in `event`, |
| 131 | 174 | so the server follows what it did not write itself while anyone is |
| 132 | 175 | 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 | ||
| 138 | 181 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 139 | 182 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 140 | 183 | 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. | |
| 146 | 192 | |
| 147 | 193 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 148 | 194 | `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"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { |
| 6 | acceptCounter, | |
| 7 | addRemark, | |
| 8 | canDeleteRemark, | |
| 6 | 9 | canWithdraw, |
| 10 | deleteRemark, | |
| 7 | 11 | getProposal, |
| 8 | 12 | health, |
| 9 | 13 | listProposals, |
| 14 | listRemarks, | |
| 10 | 15 | propose, |
| 11 | 16 | withdrawProposal, |
| 12 | 17 | type AdminHealth, |
| @@ -247,6 +252,286 @@ test("a state that is not a state is refused, and a limit is bounded", () => { | ||
| 247 | 252 | expect(capped.ok && capped.value.length).toBe(1); |
| 248 | 253 | }); |
| 249 | 254 | |
| 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 | ||
| 250 | 535 | const PUBLIC_HEALTH = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"]; |
| 251 | 536 | |
| 252 | 537 | 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 { | ||
| 70 | 70 | readonly options: readonly Option[]; |
| 71 | 71 | } |
| 72 | 72 | |
| 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 | ||
| 73 | 94 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 74 | 95 | export type Result<T> = |
| 75 | 96 | | { ok: true; value: T } |
| @@ -240,6 +261,187 @@ export function withdrawProposal( | ||
| 240 | 261 | return ok(withdrawn); |
| 241 | 262 | } |
| 242 | 263 | |
| 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 | ||
| 243 | 445 | /** What anyone may know about this process: nothing about anybody's data. */ |
| 244 | 446 | export interface Health { |
| 245 | 447 | readonly ok: boolean; |
| @@ -327,6 +529,14 @@ function soundness(db: Database, now = Date.now()): string { | ||
| 327 | 529 | return verdict; |
| 328 | 530 | } |
| 329 | 531 | |
| 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 | ||
| 330 | 540 | /** |
| 331 | 541 | * Whether `who` may withdraw `proposal` at all. The state is the action's |
| 332 | 542 | * 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 { | ||
| 335 | 545 | export const canWithdraw = ( |
| 336 | 546 | who: Who, |
| 337 | 547 | 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[] = [ | ||
| 58 | 58 | data TEXT NOT NULL, |
| 59 | 59 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 60 | 60 | )`, |
| 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)`, | |
| 61 | 77 | ]; |
| 62 | 78 | |
| 63 | 79 | export function openDb(dir = dataDir): Database { |
src/mcp.ts +48
| @@ -8,12 +8,15 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | addRemark, | |
| 11 | 12 | getProposal, |
| 12 | 13 | health, |
| 13 | 14 | listProposals, |
| 15 | listRemarks, | |
| 14 | 16 | propose, |
| 15 | 17 | withdrawProposal, |
| 16 | 18 | BODY_MAX, |
| 19 | KINDS, | |
| 17 | 20 | LABEL_MAX, |
| 18 | 21 | OPTIONS_MAX, |
| 19 | 22 | OPTIONS_MIN, |
| @@ -123,6 +126,51 @@ export const tools: readonly Tool[] = [ | ||
| 123 | 126 | }, |
| 124 | 127 | run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }), |
| 125 | 128 | }, |
| 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 | }, | |
| 126 | 174 | { |
| 127 | 175 | name: "health", |
| 128 | 176 | description: |
src/pages/about.ts +3 −1
| @@ -24,6 +24,8 @@ interface RuleEntry { | ||
| 24 | 24 | const WORKS: readonly string[] = [ |
| 25 | 25 | "Open a proposal: a title, a paragraph, two to eight options, and the rule that will decide it.", |
| 26 | 26 | "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.", | |
| 27 | 29 | "Withdraw a proposal you opened while it is open; an admin withdraws any.", |
| 28 | 30 | "Watch the list stay current as other people write, without reloading.", |
| 29 | 31 | ]; |
| @@ -35,7 +37,7 @@ const WORKS: readonly string[] = [ | ||
| 35 | 37 | */ |
| 36 | 38 | const NOT_YET: readonly string[] = [ |
| 37 | 39 | "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.", | |
| 39 | 41 | "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.", |
| 40 | 42 | "Bindings. A decision is a record here and does nothing elsewhere — no webhook, no issue, no file.", |
| 41 | 43 | ]; |
src/pages/proposal.ts +108 −4
| @@ -1,21 +1,50 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * One proposal: its title, its paragraph, its options in order, who wrote |
| 3 | 3 | * 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. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 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"; | |
| 8 | 24 | import { escape } from "../http.ts"; |
| 9 | 25 | import { describeRule } from "../quorum.ts"; |
| 10 | 26 | import type { Who } from "../who.ts"; |
| 11 | 27 | import { ago, layout, toast } from "./layout.ts"; |
| 12 | 28 | |
| 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 | ||
| 13 | 41 | export function proposalPage(input: { |
| 14 | 42 | who: Who; |
| 15 | 43 | proposal: FullProposal; |
| 44 | remarks: readonly Remark[]; | |
| 16 | 45 | notice?: string | undefined; |
| 17 | 46 | }): string { |
| 18 | const { who, proposal, notice } = input; | |
| 47 | const { who, proposal, remarks, notice } = input; | |
| 19 | 48 | const withdraw = |
| 20 | 49 | proposal.state === "open" && canWithdraw(who, proposal) |
| 21 | 50 | ? `<form method="post" action="/proposals/${proposal.id}/withdraw"> |
| @@ -23,7 +52,7 @@ export function proposalPage(input: { | ||
| 23 | 52 | </form>` |
| 24 | 53 | : ""; |
| 25 | 54 | 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"> | |
| 27 | 56 | <div class="head-row"> |
| 28 | 57 | <h2>${escape(proposal.title)}</h2> |
| 29 | 58 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> |
| @@ -39,12 +68,87 @@ export function proposalPage(input: { | ||
| 39 | 68 | .join("")}</ol> |
| 40 | 69 | <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div> |
| 41 | 70 | </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> | |
| 42 | 75 | ${toast(notice)}`, |
| 43 | 76 | who, |
| 44 | 77 | `/p/${proposal.id}`, |
| 45 | 78 | ); |
| 46 | 79 | } |
| 47 | 80 | |
| 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 | ||
| 48 | 152 | /** |
| 49 | 153 | * How this one is decided, in words, above the options. The rule was read |
| 50 | 154 | * 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | acceptCounter, | |
| 10 | addRemark, | |
| 11 | deleteRemark, | |
| 9 | 12 | getProposal, |
| 10 | 13 | health, |
| 11 | 14 | listProposals, |
| 15 | listRemarks, | |
| 12 | 16 | propose, |
| 13 | 17 | withdrawProposal, |
| 14 | 18 | type Failure, |
| @@ -45,7 +49,7 @@ export interface Context { | ||
| 45 | 49 | } |
| 46 | 50 | |
| 47 | 51 | export interface Route { |
| 48 | readonly method: "GET" | "POST"; | |
| 52 | readonly method: "GET" | "POST" | "DELETE"; | |
| 49 | 53 | readonly path: string; |
| 50 | 54 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 51 | 55 | } |
| @@ -121,8 +125,14 @@ export const routes: readonly Route[] = [ | ||
| 121 | 125 | const found = getProposal(c.db, c.who, { id: Number(c.params.id) }); |
| 122 | 126 | // Anything that is not a proposal — a bad id or a gone one — is a 404. |
| 123 | 127 | if (!found.ok) return html(missing(found.message, c.who), 404, clear); |
| 128 | const said = listRemarks(c.db, c.who, { proposalId: found.value.id }); | |
| 124 | 129 | 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 | }), | |
| 126 | 136 | 200, |
| 127 | 137 | clear, |
| 128 | 138 | ); |
| @@ -159,6 +169,50 @@ export const routes: readonly Route[] = [ | ||
| 159 | 169 | pathOf(c.params.id ?? ""), |
| 160 | 170 | ), |
| 161 | 171 | }, |
| 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 | }, | |
| 162 | 216 | |
| 163 | 217 | // The JSON API: the same actions, the same identity, plain status codes. |
| 164 | 218 | { |
| @@ -202,6 +256,42 @@ export const routes: readonly Route[] = [ | ||
| 202 | 256 | handle: (c) => |
| 203 | 257 | answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })), |
| 204 | 258 | }, |
| 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 | }, | |
| 205 | 295 | |
| 206 | 296 | // What a probe asks, forever: 200 while the app is sound, 503 when it is |
| 207 | 297 | // 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 () = | ||
| 136 | 136 | ? "off in this environment" |
| 137 | 137 | : escape(process.env.FORGE_APP_SSH), |
| 138 | 138 | ); |
| 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."]) | |
| 141 | 142 | expect(page).toContain(missing); |
| 143 | expect(page).toContain("Say something on a proposal"); | |
| 144 | expect(page).not.toContain("Deliberation."); | |
| 142 | 145 | expect(page).toContain("reading as a guest"); |
| 143 | 146 | expect(page).not.toContain("Signed in as"); |
| 144 | 147 | |
| @@ -453,6 +456,8 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 453 | 456 | "get_proposal", |
| 454 | 457 | "propose", |
| 455 | 458 | "withdraw_proposal", |
| 459 | "list_remarks", | |
| 460 | "add_remark", | |
| 456 | 461 | "health", |
| 457 | 462 | ]); |
| 458 | 463 | 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 () => | ||
| 492 | 497 | expect(notified.status).toBe(202); |
| 493 | 498 | }); |
| 494 | 499 | |
| 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("<script>alert(1)</script>"); | |
| 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 | ||
| 495 | 747 | test("/api/health answers a probe, and only an admin sees the environment", async () => { |
| 496 | 748 | const PUBLIC = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"]; |
| 497 | 749 | for (const who of [{}, alice]) { |
src/tty.ts +189 −24
| @@ -2,21 +2,32 @@ | ||
| 2 | 2 | * The app in a terminal. The forge runs this on a pty when someone |
| 3 | 3 | * `ssh`es to the app, over the same data as the page and through the same |
| 4 | 4 | * 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. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | 14 | import { |
| 15 | addRemark, | |
| 10 | 16 | canWithdraw, |
| 11 | 17 | getProposal, |
| 12 | 18 | listProposals, |
| 19 | listRemarks, | |
| 13 | 20 | propose, |
| 14 | 21 | withdrawProposal, |
| 22 | KINDS, | |
| 15 | 23 | OPTIONS_MAX, |
| 16 | 24 | type FullProposal, |
| 25 | type Kind, | |
| 17 | 26 | type ProposalSummary, |
| 27 | type Remark, | |
| 18 | 28 | } from "./actions.ts"; |
| 19 | 29 | import { openDb } from "./db.ts"; |
| 30 | import { oneLine, plain } from "./plain.ts"; | |
| 20 | 31 | import { ssh, title, where } from "./env.ts"; |
| 21 | 32 | import { describeRule, RULE_FORMS } from "./quorum.ts"; |
| 22 | 33 | import { whoAtTty } from "./who.ts"; |
| @@ -40,9 +51,19 @@ interface Draft { | ||
| 40 | 51 | typing: string; |
| 41 | 52 | } |
| 42 | 53 | |
| 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 | ||
| 43 | 63 | let cursor = 0; |
| 44 | 64 | let open: number | undefined; |
| 45 | 65 | let draft: Draft | undefined; |
| 66 | let remarkDraft: RemarkDraft | undefined; | |
| 46 | 67 | let said = ""; |
| 47 | 68 | |
| 48 | 69 | const proposals = (): ProposalSummary[] => { |
| @@ -55,23 +76,30 @@ const one = (id: number): FullProposal | undefined => { | ||
| 55 | 76 | return found.ok ? found.value : undefined; |
| 56 | 77 | }; |
| 57 | 78 | |
| 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 | */ | |
| 59 | 85 | 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 | } | |
| 70 | 99 | } |
| 71 | } | |
| 72 | lines.push(line); | |
| 73 | return lines; | |
| 74 | }); | |
| 100 | lines.push(line); | |
| 101 | return lines; | |
| 102 | }); | |
| 75 | 103 | |
| 76 | 104 | function draw(): void { |
| 77 | 105 | const lines = [ |
| @@ -80,6 +108,7 @@ function draw(): void { | ||
| 80 | 108 | "", |
| 81 | 109 | ]; |
| 82 | 110 | if (draft !== undefined) lines.push(...composer(draft)); |
| 111 | else if (remarkDraft !== undefined) lines.push(...remarkComposer(remarkDraft)); | |
| 83 | 112 | else if (open !== undefined) lines.push(...detail(open)); |
| 84 | 113 | else lines.push(...list()); |
| 85 | 114 | lines.push(""); |
| @@ -98,8 +127,9 @@ const list = (): string[] => { | ||
| 98 | 127 | ), |
| 99 | 128 | ]; |
| 100 | 129 | 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}`; | |
| 103 | 133 | }); |
| 104 | 134 | }; |
| 105 | 135 | |
| @@ -107,23 +137,59 @@ const detail = (id: number): string[] => { | ||
| 107 | 137 | const proposal = one(id); |
| 108 | 138 | if (proposal === undefined) return [dim("That proposal is gone.")]; |
| 109 | 139 | return [ |
| 110 | bold(proposal.title), | |
| 140 | bold(oneLine(proposal.title)), | |
| 111 | 141 | 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", " ")}`, | |
| 113 | 143 | ), |
| 114 | 144 | "", |
| 115 | 145 | ...wrap(proposal.body), |
| 116 | 146 | "", |
| 117 | 147 | dim( |
| 118 | 148 | proposal.rule_parsed === undefined |
| 119 | ? `A rule this app cannot read: ${proposal.rule}` | |
| 149 | ? `A rule this app cannot read: ${oneLine(proposal.rule)}` | |
| 120 | 150 | : describeRule(proposal.rule_parsed), |
| 121 | 151 | ), |
| 122 | 152 | "", |
| 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), | |
| 124 | 156 | ]; |
| 125 | 157 | }; |
| 126 | 158 | |
| 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 | ||
| 127 | 193 | const composer = (d: Draft): string[] => { |
| 128 | 194 | const cell = `${esc}7m ${esc}0m`; |
| 129 | 195 | const lines = [bold("A new proposal"), ""]; |
| @@ -156,9 +222,45 @@ const composer = (d: Draft): string[] => { | ||
| 156 | 222 | return lines; |
| 157 | 223 | }; |
| 158 | 224 | |
| 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 | ||
| 159 | 257 | const keys = (): string => { |
| 160 | 258 | 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"; | |
| 162 | 264 | return me.role === "anon" |
| 163 | 265 | ? "j/k move · Enter open · q quit · sign in through the forge to propose" |
| 164 | 266 | : "j/k move · Enter open · n propose · w withdraw · q quit"; |
| @@ -186,6 +288,27 @@ function submit(d: Draft): void { | ||
| 186 | 288 | } |
| 187 | 289 | } |
| 188 | 290 | |
| 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 | ||
| 189 | 312 | function withdraw(id: number | undefined): void { |
| 190 | 313 | if (id === undefined) { |
| 191 | 314 | said = "Nothing to withdraw."; |
| @@ -244,6 +367,35 @@ process.stdin.on("data", (key: string) => { | ||
| 244 | 367 | draw(); |
| 245 | 368 | return; |
| 246 | 369 | } |
| 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 | } | |
| 247 | 399 | if (key === "q" || key === "\x03") { |
| 248 | 400 | out(`${esc}2J${esc}H`); |
| 249 | 401 | process.exit(0); |
| @@ -273,6 +425,19 @@ process.stdin.on("data", (key: string) => { | ||
| 273 | 425 | typing: "", |
| 274 | 426 | }; |
| 275 | 427 | } |
| 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 | } | |
| 276 | 441 | if (key === "w") withdraw(under); |
| 277 | 442 | draw(); |
| 278 | 443 | }); |
static/app.css +46
| @@ -218,6 +218,52 @@ code, | ||
| 218 | 218 | font-weight: 650; |
| 219 | 219 | color: var(); |
| 220 | 220 | } |
| 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(); | |
| 226 | margin: var() 0 0; | |
| 227 | padding: 0; | |
| 228 | } | |
| 229 | .remark { | |
| 230 | background: var(); | |
| 231 | border: 1px solid var(); | |
| 232 | border-left: 3px solid var(); | |
| 233 | border-radius: var(); | |
| 234 | padding: var(); | |
| 235 | } | |
| 236 | .remark[data-kind="question"] { | |
| 237 | border-left-color: var(); | |
| 238 | } | |
| 239 | .remark[data-kind="counter"] { | |
| 240 | border-left-color: var(); | |
| 241 | } | |
| 242 | .remark .body { | |
| 243 | margin: var() 0 0; | |
| 244 | } | |
| 245 | .remark .row:empty { | |
| 246 | display: none; | |
| 247 | } | |
| 248 | .pill[data-kind="question"] { | |
| 249 | color: var(); | |
| 250 | border-color: currentColor; | |
| 251 | } | |
| 252 | .pill[data-kind="counter"] { | |
| 253 | color: var(); | |
| 254 | border-color: currentColor; | |
| 255 | } | |
| 256 | .wanted { | |
| 257 | margin-top: var(); | |
| 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 | ||
| 221 | 267 | /* Nothing to show yet: the same card, drawn as an outline, saying what this |
| 222 | 268 | app is rather than that it is empty. */ |
| 223 | 269 | .empty { |