Public: anyone can read this; the crew can change it.
A proposer fixes a typo: the title and the paragraph, edited in the open and stamped #60
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/53-a-proposer-fixes-a-typo-the-title-and-th → main 1 commit fork
14 files
changed +881
−37
README.md +24 −7
| @@ -24,7 +24,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 24 | 24 | |
| 25 | 25 | | file | what it is | |
| 26 | 26 | | ---------------------------------- | ------------------------------------------------------------ | |
| 27 | | `src/actions.ts` | propose, list, read, withdraw, remark, health — and who may | | |
| 27 | | `src/actions.ts` | propose, edit, list, read, withdraw, remark, health — who may | | |
| 28 | 28 | | `src/quorum.ts` | the three rules as plain data: read one, say it, count it | |
| 29 | 29 | | `src/plain.ts` | somebody else's text, safe to print on a terminal | |
| 30 | 30 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| @@ -33,7 +33,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 33 | 33 | | `src/backup.ts` | a copy of the record, and putting one back | |
| 34 | 34 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 35 | 35 | | `src/routes.ts` | every path, in one table | |
| 36 | | `src/pages/` | the HTML, rendered on the server: one layout, three pages | | |
| 36 | | `src/pages/` | the HTML, rendered on the server: one layout, four pages | | |
| 37 | 37 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 38 | 38 | | `src/server.ts` | boot: open the database, serve | |
| 39 | 39 | | `src/tty.ts` | the terminal, over the same actions | |
| @@ -47,12 +47,13 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 47 | 47 | |
| 48 | 48 | - **anon** — anyone. Reads. |
| 49 | 49 | - **user** — signed in to the forge and able to see this app. Makes |
| 50 | proposals, as themselves, withdraws their own, says things on anybody's | |
| 51 | and deletes what they said; on a proposal they opened, they can accept a | |
| 52 | counter. | |
| 50 | proposals, as themselves, corrects and withdraws their own, says things on | |
| 51 | anybody's and deletes what they said; on a proposal they opened, they can | |
| 52 | accept a counter. | |
| 53 | 53 | - **admin** — able to write this repository. Withdraws any proposal, deletes |
| 54 | 54 | any remark, accepts a counter on any proposal, and takes a copy of the |
| 55 | whole record. | |
| 55 | whole record. Not editing anybody's text: an admin takes things away, and | |
| 56 | does not put words under somebody else's name. | |
| 56 | 57 | |
| 57 | 58 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 58 | 59 | `/__forge/signout`; the forge answers both on the app's own host. |
| @@ -71,6 +72,19 @@ names and count a `named:` rule needs; `/p/<id>` says the rule in words above | ||
| 71 | 72 | the options. A guest sees both pages and no forms. With nothing proposed yet, |
| 72 | 73 | `/` says in four sentences what a proposal is rather than that it is empty. |
| 73 | 74 | |
| 75 | Its proposer can correct the words: `/p/<id>/edit` is a form holding the title | |
| 76 | and the paragraph as they stand now, open to the person who wrote it and only | |
| 77 | while the proposal is open — an admin withdraws anybody's proposal and edits | |
| 78 | nobody's. **What may be edited is the title and the paragraph, and nothing | |
| 79 | else**: the options are what the group is choosing between, so changing them | |
| 80 | would change what a ballot already cast was cast for, and a counter is how an | |
| 81 | option is added. **A correction is shown rather than hidden**: `/p/<id>` says | |
| 82 | "edited" beside the proposer's name with the time it happened, the thread | |
| 83 | underneath is untouched, and saving the same words again changes nothing and | |
| 84 | claims nothing. (That mark comes from a column of its own, `edited_at`, and | |
| 85 | not from comparing `updated_at` with `created_at` — withdrawing a proposal and | |
| 86 | accepting a counter stamp `updated_at` too, and neither is an edit.) | |
| 87 | ||
| 74 | 88 | Under the proposal is the argument about it: a composer for whoever is signed |
| 75 | 89 | in, and then the thread, oldest first, each remark labelled with its kind. The |
| 76 | 90 | thread is the last thing on the page and grows at the bottom, so a remark |
| @@ -93,6 +107,8 @@ curl 'https://<app>/api/proposals?state=open' | ||
| 93 | 107 | curl https://<app>/api/proposals/1 # one, with its options in order |
| 94 | 108 | curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \ |
| 95 | 109 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}' |
| 110 | curl -X PATCH https://<app>/api/proposals/1 -H 'content-type: application/json' \ | |
| 111 | -d '{"title":"Adopt the four-day week","body":"A trial for two quarters."}' | |
| 96 | 112 | curl -X POST https://<app>/api/proposals/1/withdraw |
| 97 | 113 | curl https://<app>/api/proposals/1/remarks # the thread, oldest first |
| 98 | 114 | curl -X POST https://<app>/api/proposals/1/remarks -H 'content-type: application/json' \ |
| @@ -258,7 +274,8 @@ moves, Enter opens the proposal under the cursor — with the thread on it | ||
| 258 | 274 | underneath — `n` proposes (a title, then the paragraph, then options one per |
| 259 | 275 | line until a blank line, then the rule — Enter alone for `majority`), `c` |
| 260 | 276 | says something (`1`, `2` or `3` for the kind, then the text, then the option |
| 261 | a counter wants), `w` withdraws, Esc goes back and `q` quits. A guest is told | |
| 277 | a counter wants), `e` corrects the title and the paragraph of one you opened | |
| 278 | (each arriving already typed), `w` withdraws, Esc goes back and `q` quits. A guest is told | |
| 262 | 279 | to sign in rather than shown a composer. Nothing anyone else wrote reaches |
| 263 | 280 | that screen as a control character: it goes through `src/plain.ts` on the way |
| 264 | 281 | out, so the only escape sequences a terminal here sees are the app's own. |
src/actions.test.ts +175
| @@ -7,19 +7,24 @@ import { | ||
| 7 | 7 | addMember, |
| 8 | 8 | addRemark, |
| 9 | 9 | canDeleteRemark, |
| 10 | canEdit, | |
| 10 | 11 | canWithdraw, |
| 11 | 12 | deleteRemark, |
| 13 | editProposal, | |
| 12 | 14 | getProposal, |
| 13 | 15 | health, |
| 14 | 16 | isMember, |
| 15 | 17 | listMembers, |
| 16 | 18 | listProposals, |
| 17 | 19 | listRemarks, |
| 20 | BODY_MAX, | |
| 18 | 21 | MEMBER_NAME_MAX, |
| 22 | OPTIONS_LOCKED, | |
| 19 | 23 | PERSON_ID_MAX, |
| 20 | 24 | propose, |
| 21 | 25 | removeMember, |
| 22 | 26 | ROSTER_MAX, |
| 27 | TITLE_MAX, | |
| 23 | 28 | withdrawProposal, |
| 24 | 29 | type AdminHealth, |
| 25 | 30 | } from "./actions.ts"; |
| @@ -46,6 +51,11 @@ const made = (who: Who = alice, input: Record<string, unknown> = {}) => { | ||
| 46 | 51 | if (!r.ok) throw new Error(`${r.error}: ${r.message}`); |
| 47 | 52 | return r.value; |
| 48 | 53 | }; |
| 54 | /** The title on the record, for a refusal that must have changed nothing. */ | |
| 55 | const title = (id: number): string | undefined => { | |
| 56 | const found = getProposal(db, anon, { id }); | |
| 57 | return found.ok ? found.value.title : undefined; | |
| 58 | }; | |
| 49 | 59 | |
| 50 | 60 | test("anyone reads; only a signed-in person proposes, as themselves", () => { |
| 51 | 61 | expect(listProposals(db, anon)).toEqual({ ok: true, value: [] }); |
| @@ -254,6 +264,171 @@ test("only the author or an admin withdraws, and only once", () => { | ||
| 254 | 264 | if (!nowhere.ok) expect(nowhere.error).toBe("not_found"); |
| 255 | 265 | }); |
| 256 | 266 | |
| 267 | test("the proposer corrects the text; nobody else does, not even an admin", () => { | |
| 268 | const mine = made(alice, { title: "Where do we met" }); | |
| 269 | expect(canEdit(alice, mine)).toBe(true); | |
| 270 | expect(canEdit(bob, mine)).toBe(false); | |
| 271 | // An admin withdraws anybody's proposal and edits nobody's: editing puts | |
| 272 | // words under someone else's name. | |
| 273 | expect(canEdit(root, mine)).toBe(false); | |
| 274 | expect(canEdit(anon, mine)).toBe(false); | |
| 275 | expect(mine.edited_at).toBeNull(); | |
| 276 | ||
| 277 | const byBob = editProposal(db, bob, { | |
| 278 | id: mine.id, | |
| 279 | title: "Where do we meet", | |
| 280 | body: mine.body, | |
| 281 | }); | |
| 282 | expect(byBob.ok).toBe(false); | |
| 283 | if (!byBob.ok) { | |
| 284 | expect(byBob.error).toBe("forbidden"); | |
| 285 | expect(byBob.message).toBe("Only the proposer edits a proposal."); | |
| 286 | } | |
| 287 | const byRoot = editProposal(db, root, { | |
| 288 | id: mine.id, | |
| 289 | title: "Where do we meet", | |
| 290 | body: mine.body, | |
| 291 | }); | |
| 292 | expect(byRoot.ok).toBe(false); | |
| 293 | if (!byRoot.ok) expect(byRoot.error).toBe("forbidden"); | |
| 294 | const byAnon = editProposal(db, anon, { | |
| 295 | id: mine.id, | |
| 296 | title: "Where do we meet", | |
| 297 | body: mine.body, | |
| 298 | }); | |
| 299 | expect(byAnon.ok).toBe(false); | |
| 300 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); | |
| 301 | // Refused is refused: the text on the record is the text nobody changed. | |
| 302 | expect(getProposal(db, anon, { id: mine.id }).ok && title(mine.id)).toBe( | |
| 303 | "Where do we met", | |
| 304 | ); | |
| 305 | ||
| 306 | const heard: LiveEvent[] = []; | |
| 307 | const stop = subscribe((event) => heard.push(event)); | |
| 308 | const fixed = editProposal(db, alice, { | |
| 309 | id: mine.id, | |
| 310 | title: " Where do we meet ", | |
| 311 | body: " We need one warm room for the retrospective. ", | |
| 312 | }); | |
| 313 | expect(fixed.ok).toBe(true); | |
| 314 | if (!fixed.ok) return stop(); | |
| 315 | expect(fixed.value.title).toBe("Where do we meet"); | |
| 316 | expect(fixed.value.body).toBe("We need one warm room for the retrospective."); | |
| 317 | expect(fixed.value.edited_at).not.toBeNull(); | |
| 318 | expect(fixed.value.updated_at >= fixed.value.created_at).toBe(true); | |
| 319 | expect(heard.map((e) => e.type)).toEqual(["proposal.edited"]); | |
| 320 | ||
| 321 | // Saving the same words again is not an edit: no second event, and the | |
| 322 | // stamp does not move. | |
| 323 | const same = editProposal(db, alice, { | |
| 324 | id: mine.id, | |
| 325 | title: "Where do we meet", | |
| 326 | body: "We need one warm room for the retrospective.", | |
| 327 | }); | |
| 328 | expect(same.ok).toBe(true); | |
| 329 | if (same.ok) expect(same.value.edited_at).toBe(fixed.value.edited_at); | |
| 330 | expect(heard.map((e) => e.type)).toEqual(["proposal.edited"]); | |
| 331 | stop(); | |
| 332 | ||
| 333 | // The options are untouched, and the thread underneath is still there. | |
| 334 | const after = getProposal(db, anon, { id: mine.id }); | |
| 335 | expect(after.ok && after.value.options.map((o) => o.label)).toEqual([ | |
| 336 | "The kitchen", | |
| 337 | "The garden", | |
| 338 | ]); | |
| 339 | }); | |
| 340 | ||
| 341 | test("an edit that changes nothing on a proposal nobody edited stamps nothing", () => { | |
| 342 | const mine = made(alice); | |
| 343 | const same = editProposal(db, alice, { | |
| 344 | id: mine.id, | |
| 345 | title: mine.title, | |
| 346 | body: mine.body, | |
| 347 | }); | |
| 348 | expect(same.ok).toBe(true); | |
| 349 | // The mark on the page means somebody really changed the words. | |
| 350 | if (same.ok) expect(same.value.edited_at).toBeNull(); | |
| 351 | }); | |
| 352 | ||
| 353 | test("a correction is judged by the sentences propose uses, and never the options", () => { | |
| 354 | const mine = made(alice); | |
| 355 | const long = editProposal(db, alice, { | |
| 356 | id: mine.id, | |
| 357 | title: "x".repeat(TITLE_MAX + 1), | |
| 358 | body: mine.body, | |
| 359 | }); | |
| 360 | const longAtBirth = propose(db, alice, { | |
| 361 | ...good, | |
| 362 | title: "x".repeat(TITLE_MAX + 1), | |
| 363 | }); | |
| 364 | expect(long.ok).toBe(false); | |
| 365 | expect(longAtBirth.ok).toBe(false); | |
| 366 | if (!long.ok && !longAtBirth.ok) { | |
| 367 | expect(long.error).toBe("invalid"); | |
| 368 | expect(long.message).toBe(longAtBirth.message); | |
| 369 | } | |
| 370 | for (const body of ["", " ", "x".repeat(BODY_MAX + 1)]) { | |
| 371 | const bad = editProposal(db, alice, { id: mine.id, title: mine.title, body }); | |
| 372 | expect(bad.ok).toBe(false); | |
| 373 | if (!bad.ok) expect(bad.error).toBe("invalid"); | |
| 374 | } | |
| 375 | ||
| 376 | // Options at this door are refused out loud, not dropped in silence. | |
| 377 | const withOptions = editProposal(db, alice, { | |
| 378 | id: mine.id, | |
| 379 | title: "A better title", | |
| 380 | body: mine.body, | |
| 381 | options: ["Yes", "No", "Later"], | |
| 382 | }); | |
| 383 | expect(withOptions.ok).toBe(false); | |
| 384 | if (!withOptions.ok) { | |
| 385 | expect(withOptions.error).toBe("invalid"); | |
| 386 | expect(withOptions.message).toBe(OPTIONS_LOCKED); | |
| 387 | } | |
| 388 | expect(title(mine.id)).toBe(mine.title); | |
| 389 | ||
| 390 | const odd = editProposal(db, alice, { id: 1.5, title: "x", body: "y" }); | |
| 391 | expect(odd.ok && "impossible").toBe(false); | |
| 392 | if (!odd.ok) expect(odd.error).toBe("invalid"); | |
| 393 | const nowhere = editProposal(db, alice, { id: 9999, title: "x", body: "y" }); | |
| 394 | expect(nowhere.ok).toBe(false); | |
| 395 | if (!nowhere.ok) expect(nowhere.error).toBe("not_found"); | |
| 396 | }); | |
| 397 | ||
| 398 | test("a proposal that is no longer open keeps its text: that text is the record", () => { | |
| 399 | const mine = made(alice); | |
| 400 | expect(withdrawProposal(db, alice, { id: mine.id }).ok).toBe(true); | |
| 401 | const late = editProposal(db, alice, { | |
| 402 | id: mine.id, | |
| 403 | title: "Second thoughts", | |
| 404 | body: mine.body, | |
| 405 | }); | |
| 406 | expect(late.ok).toBe(false); | |
| 407 | if (!late.ok) { | |
| 408 | expect(late.error).toBe("invalid"); | |
| 409 | expect(late.message).toContain("withdrawn"); | |
| 410 | } | |
| 411 | expect(title(mine.id)).toBe(mine.title); | |
| 412 | ||
| 413 | // And a decided one. Nothing votes yet, so the state is set by hand — the | |
| 414 | // rule is the proposal's state and not how it got there. | |
| 415 | const chosen = made(alice); | |
| 416 | db.query("UPDATE proposal SET state = 'decided' WHERE id = ?1").run( | |
| 417 | chosen.id, | |
| 418 | ); | |
| 419 | const after = editProposal(db, alice, { | |
| 420 | id: chosen.id, | |
| 421 | title: "A tidier question", | |
| 422 | body: chosen.body, | |
| 423 | }); | |
| 424 | expect(after.ok).toBe(false); | |
| 425 | if (!after.ok) { | |
| 426 | expect(after.error).toBe("invalid"); | |
| 427 | expect(after.message).toContain("decided"); | |
| 428 | } | |
| 429 | expect(title(chosen.id)).toBe(chosen.title); | |
| 430 | }); | |
| 431 | ||
| 257 | 432 | test("a proposal refused writes nothing at all", () => { |
| 258 | 433 | const before = listProposals(db, anon); |
| 259 | 434 | const refused = propose(db, alice, { |
src/actions.ts +109 −2
| @@ -36,6 +36,8 @@ interface ProposalRow { | ||
| 36 | 36 | readonly author_name: string; |
| 37 | 37 | readonly created_at: string; |
| 38 | 38 | readonly updated_at: string; |
| 39 | /** When the proposer last corrected the text, or null: nobody has. */ | |
| 40 | readonly edited_at: string | null; | |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | /** |
| @@ -122,6 +124,21 @@ export const OPTIONS_MAX = 8; | ||
| 122 | 124 | export const STATES: readonly State[] = ["open", "decided", "withdrawn"]; |
| 123 | 125 | /** What a rule that is not a rule is told. The forms live in `quorum.ts`. */ |
| 124 | 126 | const RULE_MESSAGE = `A rule is one of ${RULE_FORMS}.`; |
| 127 | /** | |
| 128 | * What text that will not do is told — written once, so a correction is | |
| 129 | * judged by exactly the sentence the first draft was. | |
| 130 | */ | |
| 131 | const TITLE_MESSAGE = `A title is 1 to ${TITLE_MAX} characters.`; | |
| 132 | const BODY_MESSAGE = `A paragraph is 1 to ${BODY_MAX} characters.`; | |
| 133 | /** | |
| 134 | * Why an edit never touches the options. What the group is choosing between | |
| 135 | * cannot move under the people choosing: a ballot already cast names an | |
| 136 | * option, and a proposal that swapped one would have votes for something | |
| 137 | * nobody read. An option is added by accepting a counter, in the open, and | |
| 138 | * removed by withdrawing the proposal and opening a better one. | |
| 139 | */ | |
| 140 | export const OPTIONS_LOCKED = | |
| 141 | "The options cannot be edited: a ballot already cast names one, so changing them would change what was voted on. A counter adds an option while the proposal is open."; | |
| 125 | 142 | |
| 126 | 143 | const SUMMARY = |
| 127 | 144 | "SELECT p.*, (SELECT count(*) FROM option WHERE proposal_id = p.id) AS option_count FROM proposal p"; |
| @@ -189,10 +206,10 @@ export function propose( | ||
| 189 | 206 | return fail("signin_required", "Sign in to make a proposal."); |
| 190 | 207 | const title = String(input.title ?? "").trim(); |
| 191 | 208 | if (title.length === 0 || title.length > TITLE_MAX) |
| 192 | return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); | |
| 209 | return fail("invalid", TITLE_MESSAGE); | |
| 193 | 210 | const body = String(input.body ?? "").trim(); |
| 194 | 211 | if (body.length === 0 || body.length > BODY_MAX) |
| 195 | return fail("invalid", `A paragraph is 1 to ${BODY_MAX} characters.`); | |
| 212 | return fail("invalid", BODY_MESSAGE); | |
| 196 | 213 | // The rule is the proposer's, once: the text they gave is what is stored, |
| 197 | 214 | // and `parseRule` is what says whether it is a rule at all. |
| 198 | 215 | const rule = String(input.rule ?? "").trim() || "majority"; |
| @@ -242,6 +259,61 @@ export function propose( | ||
| 242 | 259 | return ok(created); |
| 243 | 260 | } |
| 244 | 261 | |
| 262 | /** | |
| 263 | * Correct the text: the title and the paragraph, and nothing else. The | |
| 264 | * proposer only — an admin may withdraw anybody's proposal but does not put | |
| 265 | * words in anybody's mouth — and only while it is open, because a decided or | |
| 266 | * a withdrawn proposal's text is what the group decided on. The options are | |
| 267 | * not here and never will be (`OPTIONS_LOCKED`). | |
| 268 | * | |
| 269 | * An edit is stamped, not hidden: `edited_at` is set, `/p/:id` says "edited" | |
| 270 | * from it, and the thread underneath is untouched. A correction that changes | |
| 271 | * nothing succeeds and stamps nothing, so the mark on the page only ever | |
| 272 | * means somebody really did change the words. | |
| 273 | */ | |
| 274 | export function editProposal( | |
| 275 | db: Database, | |
| 276 | who: Who, | |
| 277 | input: { id: number; title: string; body: string; options?: unknown }, | |
| 278 | ): Result<Proposal> { | |
| 279 | if (who.role === "anon") | |
| 280 | return fail("signin_required", "Sign in to edit a proposal."); | |
| 281 | if (!Number.isInteger(input.id)) | |
| 282 | return fail("invalid", "A proposal id is a whole number."); | |
| 283 | const proposal = db | |
| 284 | .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 285 | .get(input.id); | |
| 286 | if (proposal === null) return fail("not_found", "No such proposal."); | |
| 287 | const refusal = editRefusal(who, proposal); | |
| 288 | if (refusal !== undefined) return refusal; | |
| 289 | // A caller that sends options is told why they are not editable rather | |
| 290 | // than having them quietly dropped. When a ballot exists on this proposal | |
| 291 | // the whole edit stops here too — that check belongs in these two lines, | |
| 292 | // beside this one, the day voting lands. | |
| 293 | if (input.options !== undefined && input.options !== null) | |
| 294 | return fail("invalid", OPTIONS_LOCKED); | |
| 295 | const title = String(input.title ?? "").trim(); | |
| 296 | if (title.length === 0 || title.length > TITLE_MAX) | |
| 297 | return fail("invalid", TITLE_MESSAGE); | |
| 298 | const body = String(input.body ?? "").trim(); | |
| 299 | if (body.length === 0 || body.length > BODY_MAX) | |
| 300 | return fail("invalid", BODY_MESSAGE); | |
| 301 | // Saving the same words is not an edit: no stamp, no event, no lie on the | |
| 302 | // page about a correction nobody made. | |
| 303 | if (title === proposal.title && body === proposal.body) | |
| 304 | return ok(read(proposal)); | |
| 305 | const edited = read( | |
| 306 | db | |
| 307 | .query< | |
| 308 | ProposalRow, | |
| 309 | [string, string, number] | |
| 310 | >("UPDATE proposal SET title = ?1, body = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), edited_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?3 RETURNING *") | |
| 311 | .get(title, body, proposal.id)!, | |
| 312 | ); | |
| 313 | emit("proposal.edited", edited); | |
| 314 | return ok(edited); | |
| 315 | } | |
| 316 | ||
| 245 | 317 | export function withdrawProposal( |
| 246 | 318 | db: Database, |
| 247 | 319 | who: Who, |
| @@ -780,6 +852,41 @@ export const canWithdraw = ( | ||
| 780 | 852 | proposal: Pick<Proposal, "author_id">, |
| 781 | 853 | ): boolean => mine(who, proposal); |
| 782 | 854 | |
| 855 | /** | |
| 856 | * Whether `who` may edit `proposal`: the proposer, and nobody else. This is | |
| 857 | * the one permission in the app that is not `mine` — an admin withdraws any | |
| 858 | * proposal and deletes any remark, both of which take something away, but | |
| 859 | * editing puts words under somebody else's name, and there is no | |
| 860 | * administrative reason to do that. An admin who wrote it is its proposer | |
| 861 | * like anybody else. Whether it is still open is the action's own check. | |
| 862 | */ | |
| 863 | export const canEdit = ( | |
| 864 | who: Who, | |
| 865 | proposal: Pick<Proposal, "author_id">, | |
| 866 | ): boolean => who.role !== "anon" && who.id === proposal.author_id; | |
| 867 | ||
| 868 | /** | |
| 869 | * Why an edit would be refused, or nothing at all when it would not: who is | |
| 870 | * asking and where the proposal stands, in the sentence the action itself | |
| 871 | * refuses with. The form on `/p/:id/edit` is drawn from this, so the page | |
| 872 | * never says something the action would then disagree with. | |
| 873 | */ | |
| 874 | export const editRefusal = ( | |
| 875 | who: Who, | |
| 876 | proposal: Pick<Proposal, "author_id" | "state">, | |
| 877 | ): Result<never> | undefined => { | |
| 878 | if (who.role === "anon") | |
| 879 | return fail("signin_required", "Sign in to edit a proposal."); | |
| 880 | if (!canEdit(who, proposal)) | |
| 881 | return fail("forbidden", "Only the proposer edits a proposal."); | |
| 882 | if (proposal.state !== "open") | |
| 883 | return fail( | |
| 884 | "invalid", | |
| 885 | `This proposal is ${proposal.state}; only an open one can be corrected, and its text is the record now.`, | |
| 886 | ); | |
| 887 | return undefined; | |
| 888 | }; | |
| 889 | ||
| 783 | 890 | /** Whether `who` may delete `remark`: its author, or an admin. */ |
| 784 | 891 | export const canDeleteRemark = ( |
| 785 | 892 | who: Who, |
src/db.test.ts +23 −12
| @@ -73,6 +73,13 @@ const tables = (db: Database): string[] => | ||
| 73 | 73 | .all() |
| 74 | 74 | .map((row) => row.name); |
| 75 | 75 | |
| 76 | /** The columns a table has, for a migration that adds one. */ | |
| 77 | const columns = (db: Database, table: string): string[] => | |
| 78 | db | |
| 79 | .query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?1)") | |
| 80 | .all(table) | |
| 81 | .map((row) => row.name); | |
| 82 | ||
| 76 | 83 | /** How many seeded rows each seeded table still holds. */ |
| 77 | 84 | const seeded = (db: Database): Record<string, number> => |
| 78 | 85 | Object.fromEntries( |
| @@ -267,17 +274,10 @@ test("a database with proposals in it takes the newest migration", () => { | ||
| 267 | 274 | |
| 268 | 275 | const db = new Database(join(dir, "app.db")); |
| 269 | 276 | db.exec("PRAGMA foreign_keys = ON"); |
| 270 | // The roster arrives with the newest migration and not before it: if | |
| 271 | // `member` were inserted among the ones production has already recorded, | |
| 272 | // this table would be here already and production would never run it. | |
| 273 | expect( | |
| 274 | db | |
| 275 | .query< | |
| 276 | { name: string }, | |
| 277 | [] | |
| 278 | >("SELECT name FROM sqlite_master WHERE name = 'member'") | |
| 279 | .get(), | |
| 280 | ).toBeNull(); | |
| 277 | // `edited_at` arrives with the newest migration and not before it: if it | |
| 278 | // were inserted among the ones production has already recorded, the column | |
| 279 | // would be here already and production would never run it. | |
| 280 | expect(columns(db, "proposal")).not.toContain("edited_at"); | |
| 281 | 281 | expect(migrate(db)).toBe(migrations.length); |
| 282 | 282 | expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" }); |
| 283 | 283 | // The rows that were there are the rows that are there: no table was |
| @@ -302,7 +302,18 @@ test("a database with proposals in it takes the newest migration", () => { | ||
| 302 | 302 | >("SELECT label, proposal_id FROM option WHERE id = 1") |
| 303 | 303 | .get(), |
| 304 | 304 | ).toEqual({ label: "The kitchen", proposal_id: 1 }); |
| 305 | // And the roster the newest migration brings is there and empty. | |
| 305 | // And the column the newest migration brings is there, null on a proposal | |
| 306 | // that existed before it — nothing was edited by adding the column. | |
| 307 | expect(columns(db, "proposal")).toContain("edited_at"); | |
| 308 | expect( | |
| 309 | db | |
| 310 | .query< | |
| 311 | { edited_at: string | null }, | |
| 312 | [] | |
| 313 | >("SELECT edited_at FROM proposal WHERE id = 1") | |
| 314 | .get()?.edited_at, | |
| 315 | ).toBeNull(); | |
| 316 | // The roster is still there, and still one row per person. | |
| 306 | 317 | expect( |
| 307 | 318 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM member").get()?.n, |
| 308 | 319 | ).toBe(0); |
src/db.ts +6
| @@ -91,6 +91,12 @@ export const migrations: string[] = [ | ||
| 91 | 91 | added_by TEXT NOT NULL, |
| 92 | 92 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 93 | 93 | )`, |
| 94 | // When the proposer last corrected the text, and null until they do. Not | |
| 95 | // `updated_at`: a withdrawal and an accepted counter stamp that one too, | |
| 96 | // and a page that read it would say "edited" about a proposal nobody | |
| 97 | // edited. One column, no history table — the correction is shown, the | |
| 98 | // draft before it is not kept. | |
| 99 | `ALTER TABLE proposal ADD COLUMN edited_at TEXT`, | |
| 94 | 100 | ]; |
| 95 | 101 | |
| 96 | 102 | /** |
src/live.test.ts +49
| @@ -2,6 +2,7 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { propose } from "./actions.ts"; | |
| 5 | 6 | import { openDb } from "./db.ts"; |
| 6 | 7 | import { |
| 7 | 8 | emit, |
| @@ -243,6 +244,53 @@ test("a write by another process reaches an open page, through the same action", | ||
| 243 | 244 | await reader.cancel(); |
| 244 | 245 | }); |
| 245 | 246 | |
| 247 | test("a correction made by another process reaches an open page too", async () => { | |
| 248 | // The proposal is opened here; the edit comes from somewhere else — the | |
| 249 | // terminal, or a script — and the page this process is serving hears it. | |
| 250 | const opened = propose(other, { role: "user", id: "u1", name: "tty" }, { | |
| 251 | title: "Where do we met", | |
| 252 | body: "One room for the retrospective.", | |
| 253 | options: ["The kitchen", "The garden"], | |
| 254 | }); | |
| 255 | if (!opened.ok) throw new Error(opened.message); | |
| 256 | const stream = await fetch(base + "/events"); | |
| 257 | const reader = stream.body!.getReader(); | |
| 258 | await reader.read(); // the retry line: the stream is open and following | |
| 259 | const child = Bun.spawnSync({ | |
| 260 | cmd: [ | |
| 261 | "bun", | |
| 262 | "-e", | |
| 263 | `import { editProposal } from ${JSON.stringify(join(import.meta.dir, "actions.ts"))}; | |
| 264 | import { openDb } from ${JSON.stringify(join(import.meta.dir, "db.ts"))}; | |
| 265 | const saved = editProposal(openDb(), { role: "user", id: "u1", name: "tty" }, { | |
| 266 | id: ${opened.value.id}, | |
| 267 | title: "Where do we meet", | |
| 268 | body: "One warm room for the retrospective.", | |
| 269 | }); | |
| 270 | if (!saved.ok) throw new Error(saved.message);`, | |
| 271 | ], | |
| 272 | env: { ...process.env, FORGE_APP_DATA: dir }, | |
| 273 | }); | |
| 274 | expect(child.stderr.toString()).toBe(""); | |
| 275 | expect(child.exitCode).toBe(0); | |
| 276 | let buffer = ""; | |
| 277 | const deadline = Date.now() + 3000; | |
| 278 | while (!buffer.includes("proposal.edited") && Date.now() < deadline) { | |
| 279 | const read = await Promise.race([ | |
| 280 | reader.read(), | |
| 281 | Bun.sleep(3000).then(() => ({ value: undefined })), | |
| 282 | ]); | |
| 283 | buffer += new TextDecoder().decode(read.value); | |
| 284 | } | |
| 285 | expect(buffer).toContain("Where do we meet"); | |
| 286 | expect(buffer).toContain("A proposal's text was corrected"); | |
| 287 | await reader.cancel(); | |
| 288 | // And the page a browser would reload says the same thing. | |
| 289 | const page = await (await fetch(`${base}/p/${opened.value.id}`)).text(); | |
| 290 | expect(page).toContain("One warm room for the retrospective."); | |
| 291 | expect(page).toContain(">edited <time"); | |
| 292 | }); | |
| 293 | ||
| 246 | 294 | test("every event carries the one line the page announces", async () => { |
| 247 | 295 | // The announcement is the app's own sentence, from the type alone: a |
| 248 | 296 | // reader hears what happened instead of the whole region read again. The |
| @@ -250,6 +298,7 @@ test("every event carries the one line the page announces", async () => { | ||
| 250 | 298 | // roster inherit them rather than each inventing one. |
| 251 | 299 | expect(summarize("proposal.opened")).toBe("A new proposal"); |
| 252 | 300 | expect(summarize("proposal.withdrawn")).toBe("A proposal was withdrawn"); |
| 301 | expect(summarize("proposal.edited")).toBe("A proposal's text was corrected"); | |
| 253 | 302 | expect(summarize("remark.added")).toBe("A new remark"); |
| 254 | 303 | expect(summarize("remark.removed")).toBe("A remark was deleted"); |
| 255 | 304 | expect(summarize("remark.accepted")).toBe("A counter became an option"); |
src/live.ts +1
| @@ -35,6 +35,7 @@ export interface LiveEvent { | ||
| 35 | 35 | const SUMMARIES: Record<string, string> = { |
| 36 | 36 | "proposal.opened": "A new proposal", |
| 37 | 37 | "proposal.withdrawn": "A proposal was withdrawn", |
| 38 | "proposal.edited": "A proposal's text was corrected", | |
| 38 | 39 | "remark.added": "A new remark", |
| 39 | 40 | "remark.removed": "A remark was deleted", |
| 40 | 41 | "remark.accepted": "A counter became an option", |
src/mcp.ts +24
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | 11 | addRemark, |
| 12 | editProposal, | |
| 12 | 13 | getProposal, |
| 13 | 14 | health, |
| 14 | 15 | listProposals, |
| @@ -115,6 +116,29 @@ export const tools: readonly Tool[] = [ | ||
| 115 | 116 | : String(args.rule), |
| 116 | 117 | }), |
| 117 | 118 | }, |
| 119 | { | |
| 120 | name: "edit_proposal", | |
| 121 | description: | |
| 122 | "Correct the title and the paragraph of a proposal you opened, while it is open. Both fields every time: this is the text as it should now read, not a diff. The options cannot be edited at any door — add one by having its proposer accept a counter. The correction is shown on the proposal with the time it was made.", | |
| 123 | inputSchema: { | |
| 124 | type: "object", | |
| 125 | properties: { | |
| 126 | id: { type: "integer", minimum: 1 }, | |
| 127 | title: { type: "string", minLength: 1, maxLength: TITLE_MAX }, | |
| 128 | body: { type: "string", minLength: 1, maxLength: BODY_MAX }, | |
| 129 | }, | |
| 130 | required: ["id", "title", "body"], | |
| 131 | }, | |
| 132 | run: (db, who, args) => | |
| 133 | editProposal(db, who, { | |
| 134 | id: Number(args.id), | |
| 135 | title: String(args.title ?? ""), | |
| 136 | body: String(args.body ?? ""), | |
| 137 | // Not in the schema, and still refused out loud if an agent sends | |
| 138 | // them: a tool that ignored a field would look as if it had taken it. | |
| 139 | options: args.options, | |
| 140 | }), | |
| 141 | }, | |
| 118 | 142 | { |
| 119 | 143 | name: "withdraw_proposal", |
| 120 | 144 | description: |
src/pages/about.ts +1
| @@ -27,6 +27,7 @@ const WORKS: readonly string[] = [ | ||
| 27 | 27 | "Read every proposal and any one of them, signed in or not.", |
| 28 | 28 | "Say something on a proposal: a comment, a question for its author, or a counter arguing for another option — and delete your own again.", |
| 29 | 29 | "Accept a counter on a proposal you opened: the option it names is added to it, and every ballot already cast still stands.", |
| 30 | "Correct the title and the paragraph of a proposal you opened while it is open — the options are not editable — and the proposal says it was corrected, with the time.", | |
| 30 | 31 | "Withdraw a proposal you opened while it is open; an admin withdraws any.", |
| 31 | 32 | "Watch the list stay current as other people write, without reloading.", |
| 32 | 33 | "Take the whole record away as a file: an admin downloads the database or its JSON, and puts one back at the terminal.", |
src/pages/edit.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * Correcting a proposal: the title and the paragraph, in a form already | |
| 3 | * holding what is there now. A page of its own rather than a disclosure on | |
| 4 | * the proposal, because the proposal is a live region and a form inside one | |
| 5 | * is a form that can be swapped out from under somebody's hands. | |
| 6 | * | |
| 7 | * The options are shown here and cannot be touched: they are what the group | |
| 8 | * is choosing between, and the page says so in the same sentence the action | |
| 9 | * refuses with. There is no JavaScript in any of this — it is a form, it | |
| 10 | * posts, and the proposal comes back. | |
| 11 | */ | |
| 12 | ||
| 13 | import { | |
| 14 | BODY_MAX, | |
| 15 | OPTIONS_LOCKED, | |
| 16 | TITLE_MAX, | |
| 17 | type FullProposal, | |
| 18 | } from "../actions.ts"; | |
| 19 | import { escape } from "../http.ts"; | |
| 20 | import type { Who } from "../who.ts"; | |
| 21 | import { layout, toast } from "./layout.ts"; | |
| 22 | ||
| 23 | export function editPage(input: { | |
| 24 | who: Who; | |
| 25 | proposal: FullProposal; | |
| 26 | notice?: string | undefined; | |
| 27 | }): string { | |
| 28 | const { who, proposal, notice } = input; | |
| 29 | const back = `/p/${proposal.id}`; | |
| 30 | return layout( | |
| 31 | `<section class="card"> | |
| 32 | <h2>Correct this proposal</h2> | |
| 33 | <p class="muted small">The words, so the group argues about what you meant. | |
| 34 | Everyone sees that it was corrected, and when.</p> | |
| 35 | <form class="stack" method="post" action="/proposals/${proposal.id}/edit"> | |
| 36 | <label class="field"> | |
| 37 | <span>Title</span> | |
| 38 | <input class="input" id="title" name="title" value="${escape(proposal.title)}" required maxlength="${TITLE_MAX}" autocomplete="off"> | |
| 39 | </label> | |
| 40 | <label class="field"> | |
| 41 | <span>Paragraph</span> | |
| 42 | <textarea class="input" id="body" name="body" rows="6" required maxlength="${BODY_MAX}">${escape(proposal.body)}</textarea> | |
| 43 | </label> | |
| 44 | <div class="row"> | |
| 45 | <button class="button" id="save">Save the correction</button> | |
| 46 | <a class="button button-quiet" href="${escape(back)}">Cancel</a> | |
| 47 | </div> | |
| 48 | </form> | |
| 49 | </section> | |
| 50 | <section class="card"> | |
| 51 | <h3>The options stay as they are</h3> | |
| 52 | <ol class="options">${proposal.options | |
| 53 | .map((option) => `<li>${escape(option.label)}</li>`) | |
| 54 | .join("")}</ol> | |
| 55 | <p class="muted small">${escape(OPTIONS_LOCKED)}</p> | |
| 56 | </section> | |
| 57 | ${toast(notice)}`, | |
| 58 | who, | |
| 59 | `/p/${proposal.id}/edit`, | |
| 60 | ); | |
| 61 | } |
src/pages/proposal.ts +25 −6
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * One proposal: its title, its paragraph, its options in order, who wrote |
| 3 | * it and where it stands — and a Withdraw form for whoever may withdraw it | |
| 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. | |
| 3 | * it and where it stands — an Edit link for its proposer and a Withdraw form | |
| 4 | * for whoever may withdraw it, while it is open. Below it the argument: a | |
| 5 | * composer for whoever is signed in, and then the thread, oldest first. A | |
| 6 | * guest sees all of it and no forms. | |
| 6 | 7 | * |
| 7 | 8 | * The composer sits above the thread on purpose. The thread is the live |
| 8 | 9 | * region, it grows at the bottom, and it is the last thing on the page — so |
| @@ -20,6 +21,7 @@ | ||
| 20 | 21 | import { |
| 21 | 22 | canAcceptCounter, |
| 22 | 23 | canDeleteRemark, |
| 24 | canEdit, | |
| 23 | 25 | canWithdraw, |
| 24 | 26 | KINDS, |
| 25 | 27 | BODY_MAX, |
| @@ -58,14 +60,20 @@ export function proposalPage(input: { | ||
| 58 | 60 | <button class="button button-quiet" id="withdraw">Withdraw</button> |
| 59 | 61 | </form>` |
| 60 | 62 | : ""; |
| 63 | // The proposer corrects their own words, and only while it is open; the | |
| 64 | // options are not on that page, and the action would refuse them anyway. | |
| 65 | const edit = | |
| 66 | proposal.state === "open" && canEdit(who, proposal) | |
| 67 | ? `<a class="button button-quiet" id="edit" href="/p/${proposal.id}/edit">Edit</a>` | |
| 68 | : ""; | |
| 61 | 69 | return layout( |
| 62 | 70 | `${liveStatus("proposal")} |
| 63 | <section id="proposal" data-live="proposal.opened proposal.withdrawn remark.accepted" data-status="proposal-status" aria-live="polite" class="card"> | |
| 71 | <section id="proposal" data-live="proposal.opened proposal.withdrawn proposal.edited remark.accepted" data-status="proposal-status" aria-live="polite" class="card"> | |
| 64 | 72 | <div class="head-row"> |
| 65 | 73 | <h2>${escape(proposal.title)}</h2> |
| 66 | 74 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> |
| 67 | 75 | </div> |
| 68 | <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time></p> | |
| 76 | <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time>${edited(proposal)}</p> | |
| 69 | 77 | <p class="body">${escape(proposal.body)}</p> |
| 70 | 78 | <p class="rule small">${rule(proposal)}</p> |
| 71 | 79 | <ol class="options">${proposal.options |
| @@ -74,7 +82,7 @@ export function proposalPage(input: { | ||
| 74 | 82 | `<li${option.id === proposal.decided_option_id ? ` class="chosen"` : ""}>${escape(option.label)}</li>`, |
| 75 | 83 | ) |
| 76 | 84 | .join("")}</ol> |
| 77 | <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div> | |
| 85 | <div class="row">${edit}${withdraw}<a class="button button-quiet" href="/">All proposals</a></div> | |
| 78 | 86 | </section> |
| 79 | 87 | <section class="card">${composer(who, proposal)}</section> |
| 80 | 88 | ${liveStatus("thread")} |
| @@ -88,6 +96,17 @@ export function proposalPage(input: { | ||
| 88 | 96 | ); |
| 89 | 97 | } |
| 90 | 98 | |
| 99 | /** | |
| 100 | * That the text was corrected, beside who wrote it and when — never | |
| 101 | * silently, and never claimed of a proposal nobody edited: `edited_at` is | |
| 102 | * set by the edit alone, where `updated_at` also moves when a proposal is | |
| 103 | * withdrawn or takes a counter's option. | |
| 104 | */ | |
| 105 | const edited = (proposal: FullProposal): string => | |
| 106 | proposal.edited_at === null | |
| 107 | ? "" | |
| 108 | : ` · <span class="edited">edited <time datetime="${escape(proposal.edited_at)}">${ago(proposal.edited_at)}</time></span>`; | |
| 109 | ||
| 91 | 110 | /** Where a remark is written. A guest is told how to get one instead. */ |
| 92 | 111 | const composer = (who: Who, proposal: FullProposal): string => |
| 93 | 112 | who.role === "anon" |
src/routes.ts +70 −1
| @@ -9,6 +9,8 @@ import { | ||
| 9 | 9 | acceptCounter, |
| 10 | 10 | addRemark, |
| 11 | 11 | deleteRemark, |
| 12 | editProposal, | |
| 13 | editRefusal, | |
| 12 | 14 | exportRecord, |
| 13 | 15 | exportSnapshot, |
| 14 | 16 | getProposal, |
| @@ -36,6 +38,7 @@ import { | ||
| 36 | 38 | import { events } from "./live.ts"; |
| 37 | 39 | import { rpc } from "./mcp.ts"; |
| 38 | 40 | import { about } from "./pages/about.ts"; |
| 41 | import { editPage } from "./pages/edit.ts"; | |
| 39 | 42 | import { home } from "./pages/home.ts"; |
| 40 | 43 | import { missing } from "./pages/layout.ts"; |
| 41 | 44 | import { proposalPage } from "./pages/proposal.ts"; |
| @@ -53,7 +56,7 @@ export interface Context { | ||
| 53 | 56 | } |
| 54 | 57 | |
| 55 | 58 | export interface Route { |
| 56 | readonly method: "GET" | "POST" | "DELETE"; | |
| 59 | readonly method: "GET" | "POST" | "PATCH" | "DELETE"; | |
| 57 | 60 | readonly path: string; |
| 58 | 61 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 59 | 62 | } |
| @@ -100,6 +103,27 @@ const given = (value: unknown): string | undefined => | ||
| 100 | 103 | const optionsOf = (value: unknown): unknown[] => |
| 101 | 104 | Array.isArray(value) ? value : typeof value === "string" ? lines(value) : []; |
| 102 | 105 | |
| 106 | /** | |
| 107 | * A correction over JSON, at `PATCH /api/proposals/:id` and at `POST` of the | |
| 108 | * same path for a client that speaks no PATCH. Both fields every time: an | |
| 109 | * edit is the text as it should now read, never a diff. `options` is passed | |
| 110 | * through untouched so the action can say why it will not take them, rather | |
| 111 | * than this door dropping them quietly. | |
| 112 | */ | |
| 113 | const editJson = async (c: Context): Promise<Response> => { | |
| 114 | const body = await readJson(c.request); | |
| 115 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 116 | const input = fields(body); | |
| 117 | return answer( | |
| 118 | editProposal(c.db, c.who, { | |
| 119 | id: Number(c.params.id), | |
| 120 | title: String(input.title ?? ""), | |
| 121 | body: String(input.body ?? ""), | |
| 122 | options: input.options, | |
| 123 | }), | |
| 124 | ); | |
| 125 | }; | |
| 126 | ||
| 103 | 127 | export const routes: readonly Route[] = [ |
| 104 | 128 | // The pages. Forms post and come back; the lists stay live over /events. |
| 105 | 129 | { |
| @@ -154,6 +178,49 @@ export const routes: readonly Route[] = [ | ||
| 154 | 178 | ); |
| 155 | 179 | }, |
| 156 | 180 | }, |
| 181 | // Correcting one: a page of its own, drawn only for someone the action | |
| 182 | // would let through, and saying what the action would say when it would | |
| 183 | // not. The options are not on it. | |
| 184 | { | |
| 185 | method: "GET", | |
| 186 | path: "/p/:id/edit", | |
| 187 | handle: (c) => { | |
| 188 | const { notice, clear } = readNotice(c.request); | |
| 189 | const found = getProposal(c.db, c.who, { id: Number(c.params.id) }); | |
| 190 | if (!found.ok) return html(missing(found.message, c.who), 404, clear); | |
| 191 | const refused = editRefusal(c.who, found.value); | |
| 192 | if (refused !== undefined) | |
| 193 | return html( | |
| 194 | missing(refused.message, c.who), | |
| 195 | STATUS[refused.error], | |
| 196 | clear, | |
| 197 | ); | |
| 198 | return html( | |
| 199 | editPage({ who: c.who, proposal: found.value, notice }), | |
| 200 | 200, | |
| 201 | clear, | |
| 202 | ); | |
| 203 | }, | |
| 204 | }, | |
| 205 | { | |
| 206 | method: "POST", | |
| 207 | path: "/proposals/:id/edit", | |
| 208 | handle: async (c) => { | |
| 209 | const form = await readForm(c.request); | |
| 210 | if (form === undefined) return text("too large", 413); | |
| 211 | const where = pathOf(c.params.id ?? ""); | |
| 212 | const saved = editProposal(c.db, c.who, { | |
| 213 | id: Number(c.params.id), | |
| 214 | title: String(form.get("title") ?? ""), | |
| 215 | body: String(form.get("body") ?? ""), | |
| 216 | }); | |
| 217 | // Saved, and the proposal is what you wanted to see; refused, and the | |
| 218 | // form is, with the sentence saying what to change. | |
| 219 | return saved.ok | |
| 220 | ? back(undefined, where) | |
| 221 | : back(saved.message, where === "/" ? "/" : `${where}/edit`); | |
| 222 | }, | |
| 223 | }, | |
| 157 | 224 | { |
| 158 | 225 | method: "POST", |
| 159 | 226 | path: "/proposals", |
| @@ -266,6 +333,8 @@ export const routes: readonly Route[] = [ | ||
| 266 | 333 | ); |
| 267 | 334 | }, |
| 268 | 335 | }, |
| 336 | { method: "PATCH", path: "/api/proposals/:id", handle: editJson }, | |
| 337 | { method: "POST", path: "/api/proposals/:id", handle: editJson }, | |
| 269 | 338 | { |
| 270 | 339 | method: "POST", |
| 271 | 340 | path: "/api/proposals/:id/withdraw", |
src/server.test.ts +198 −5
| @@ -12,11 +12,8 @@ import { serve } from "./server.ts"; | ||
| 12 | 12 | import { sign } from "./who.ts"; |
| 13 | 13 | |
| 14 | 14 | const KEY = "a-key-only-the-forge-holds"; |
| 15 | const server = serve({ | |
| 16 | port: 0, | |
| 17 | dataDir: mkdtempSync(join(tmpdir(), "app-")), | |
| 18 | identityKey: KEY, | |
| 19 | }); | |
| 15 | const dataDir = mkdtempSync(join(tmpdir(), "app-")); | |
| 16 | const server = serve({ port: 0, dataDir, identityKey: KEY }); | |
| 20 | 17 | const base = `http://${server.hostname}:${server.port}`; |
| 21 | 18 | afterAll(() => server.stop(true)); |
| 22 | 19 | |
| @@ -442,6 +439,201 @@ test("the JSON API is the same actions with the same identity", async () => { | ||
| 442 | 439 | ).toBe(404); |
| 443 | 440 | }); |
| 444 | 441 | |
| 442 | test("a proposer fixes a typo on the page, and the page says it was fixed", async () => { | |
| 443 | const typist = as("u21", "typist"); | |
| 444 | const nosy = as("u22", "nosy"); | |
| 445 | const p = await open(typist, { title: "Where do we met" }); | |
| 446 | const path = `/p/${p.id}`; | |
| 447 | ||
| 448 | // Nobody is told about an edit that has not happened, and only the | |
| 449 | // proposer is offered the door. | |
| 450 | const guest = await (await fetch(base + path)).text(); | |
| 451 | expect(guest).toContain("Where do we met"); | |
| 452 | // "edited" is the mark beside the byline, not the word in `data-live`. | |
| 453 | expect(guest).not.toContain(">edited <time"); | |
| 454 | expect(guest).not.toContain(`href="${path}/edit"`); | |
| 455 | expect(await (await api(path, {}, nosy)).text()).not.toContain( | |
| 456 | `href="${path}/edit"`, | |
| 457 | ); | |
| 458 | expect(await (await api(path, {}, typist)).text()).toContain( | |
| 459 | `href="${path}/edit"`, | |
| 460 | ); | |
| 461 | ||
| 462 | // The form arrives already holding the text it is there to correct, and | |
| 463 | // nothing on it can change the options. | |
| 464 | const form = await api(`${path}/edit`, {}, typist); | |
| 465 | expect(form.status).toBe(200); | |
| 466 | const drawn = await form.text(); | |
| 467 | expect(drawn).toContain('value="Where do we met"'); | |
| 468 | expect(drawn).toContain("One room for the retrospective.</textarea>"); | |
| 469 | expect(drawn).toContain("The options cannot be edited"); | |
| 470 | expect(drawn).not.toContain('name="options"'); | |
| 471 | // Somebody else's proposal, and a guest: the sentence the action refuses | |
| 472 | // with, at the status the failure carries. | |
| 473 | const notMine = await api(`${path}/edit`, {}, nosy); | |
| 474 | expect(notMine.status).toBe(403); | |
| 475 | expect(await notMine.text()).toContain("Only the proposer edits a proposal."); | |
| 476 | expect((await fetch(`${base}${path}/edit`)).status).toBe(401); | |
| 477 | ||
| 478 | const save = (fields: Record<string, string>, who = typist) => | |
| 479 | api( | |
| 480 | `/proposals/${p.id}/edit`, | |
| 481 | { | |
| 482 | method: "POST", | |
| 483 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 484 | body: new URLSearchParams(fields).toString(), | |
| 485 | redirect: "manual", | |
| 486 | }, | |
| 487 | who, | |
| 488 | ); | |
| 489 | const saved = await save({ | |
| 490 | title: "Where do we meet <the kitchen?>", | |
| 491 | body: "One warm room for the retrospective.", | |
| 492 | }); | |
| 493 | expect(saved.status).toBe(303); | |
| 494 | expect(saved.headers.get("location")).toBe(path); | |
| 495 | ||
| 496 | const after = await (await fetch(base + path)).text(); | |
| 497 | // Escaped on the way out, like every other thing anybody typed. | |
| 498 | expect(after).toContain("Where do we meet <the kitchen?>"); | |
| 499 | expect(after).not.toContain("<the kitchen?>"); | |
| 500 | expect(after).toContain("One warm room for the retrospective."); | |
| 501 | expect(after).toContain(">edited <time"); | |
| 502 | // The thread is untouched, and so is the live region's list of types. | |
| 503 | expect(after).toContain( | |
| 504 | 'data-live="proposal.opened proposal.withdrawn proposal.edited remark.accepted"', | |
| 505 | ); | |
| 506 | ||
| 507 | // A refusal comes back to the form with the sentence, not to the proposal. | |
| 508 | const empty = await save({ title: "", body: "Still a paragraph." }); | |
| 509 | expect(empty.status).toBe(303); | |
| 510 | expect(empty.headers.get("location")).toBe(`${path}/edit`); | |
| 511 | expect(empty.headers.get("set-cookie")).toContain("A%20title%20is%201%20to"); | |
| 512 | expect((await save({ title: "Nope", body: "Nope." }, nosy)).status).toBe(303); | |
| 513 | expect(await (await fetch(base + path)).text()).toContain( | |
| 514 | "Where do we meet <the kitchen?>", | |
| 515 | ); | |
| 516 | }); | |
| 517 | ||
| 518 | test("a withdrawn proposal is not an edited one", async () => { | |
| 519 | const dropper = as("u23", "dropper"); | |
| 520 | const p = await open(dropper, { title: "Something to drop" }); | |
| 521 | await api(`/api/proposals/${p.id}/withdraw`, { method: "POST" }, dropper); | |
| 522 | // Withdrawing stamps `updated_at` too; only a correction says "edited". | |
| 523 | const page = await (await fetch(`${base}/p/${p.id}`)).text(); | |
| 524 | expect(page).toContain("withdrawn"); | |
| 525 | expect(page).not.toContain(">edited <time"); | |
| 526 | // And there is no door back into the text of one. | |
| 527 | expect((await api(`/p/${p.id}/edit`, {}, dropper)).status).toBe(422); | |
| 528 | }); | |
| 529 | ||
| 530 | test("an edit over the API is the same edit, at PATCH or at POST", async () => { | |
| 531 | const scribe = as("u24", "scribe"); | |
| 532 | const meddler = as("u25", "meddler"); | |
| 533 | const p = await open(scribe, { title: "Where do we met" }); | |
| 534 | const at = `/api/proposals/${p.id}`; | |
| 535 | const text = { title: "Where do we meet", body: "One warm room." }; | |
| 536 | ||
| 537 | expect((await api(at, { method: "PATCH", ...jsonBody(text) })).status).toBe( | |
| 538 | 401, | |
| 539 | ); | |
| 540 | expect( | |
| 541 | (await api(at, { method: "PATCH", ...jsonBody(text) }, meddler)).status, | |
| 542 | ).toBe(403); | |
| 543 | expect( | |
| 544 | (await api(at, { method: "PATCH", ...jsonBody(text) }, root)).status, | |
| 545 | ).toBe(403); | |
| 546 | ||
| 547 | const patched = await api(at, { method: "PATCH", ...jsonBody(text) }, scribe); | |
| 548 | expect(patched.status).toBe(200); | |
| 549 | const row = (await patched.json()) as { | |
| 550 | title: string; | |
| 551 | edited_at: string | null; | |
| 552 | }; | |
| 553 | expect(row.title).toBe("Where do we meet"); | |
| 554 | expect(row.edited_at).not.toBeNull(); | |
| 555 | ||
| 556 | // The same path, POSTed, for a client that speaks no PATCH. | |
| 557 | const posted = await api( | |
| 558 | at, | |
| 559 | { method: "POST", ...jsonBody({ ...text, body: "One warmer room." }) }, | |
| 560 | scribe, | |
| 561 | ); | |
| 562 | expect(posted.status).toBe(200); | |
| 563 | expect(((await posted.json()) as { body: string }).body).toBe( | |
| 564 | "One warmer room.", | |
| 565 | ); | |
| 566 | ||
| 567 | // The same sentences a first draft is judged by, and the options never. | |
| 568 | const long = await api( | |
| 569 | at, | |
| 570 | { method: "PATCH", ...jsonBody({ ...text, title: "x".repeat(121) }) }, | |
| 571 | scribe, | |
| 572 | ); | |
| 573 | expect(long.status).toBe(422); | |
| 574 | expect(((await long.json()) as { message: string }).message).toBe( | |
| 575 | "A title is 1 to 120 characters.", | |
| 576 | ); | |
| 577 | const options = await api( | |
| 578 | at, | |
| 579 | { method: "PATCH", ...jsonBody({ ...text, options: ["Yes", "No"] }) }, | |
| 580 | scribe, | |
| 581 | ); | |
| 582 | expect(options.status).toBe(422); | |
| 583 | expect(((await options.json()) as { message: string }).message).toContain( | |
| 584 | "The options cannot be edited", | |
| 585 | ); | |
| 586 | expect( | |
| 587 | (await api("/api/proposals/9999", { method: "PATCH", ...jsonBody(text) }, scribe)) | |
| 588 | .status, | |
| 589 | ).toBe(404); | |
| 590 | }); | |
| 591 | ||
| 592 | test("an agent corrects its own proposal, and nobody else's", async () => { | |
| 593 | const agent = as("u26", "agent"); | |
| 594 | const p = await open(agent, { title: "Where do we met" }); | |
| 595 | const call = (name: string, args: unknown, who = {}) => | |
| 596 | rpc( | |
| 597 | { | |
| 598 | jsonrpc: "2.0", | |
| 599 | id: 26, | |
| 600 | method: "tools/call", | |
| 601 | params: { name, arguments: args }, | |
| 602 | }, | |
| 603 | who, | |
| 604 | ); | |
| 605 | const fixed = await call( | |
| 606 | "edit_proposal", | |
| 607 | { id: p.id, title: "Where do we meet", body: "One warm room." }, | |
| 608 | agent, | |
| 609 | ); | |
| 610 | expect(fixed.result.isError).toBeUndefined(); | |
| 611 | expect( | |
| 612 | (JSON.parse(fixed.result.content[0].text) as { edited_at: string | null }) | |
| 613 | .edited_at, | |
| 614 | ).not.toBeNull(); | |
| 615 | // A bare caller is anonymous, and somebody else's proposal is not theirs | |
| 616 | // to rewrite. | |
| 617 | expect( | |
| 618 | (await call("edit_proposal", { id: p.id, title: "No", body: "No." })).result | |
| 619 | .isError, | |
| 620 | ).toBe(true); | |
| 621 | expect( | |
| 622 | ( | |
| 623 | await call( | |
| 624 | "edit_proposal", | |
| 625 | { id: p.id, title: "No", body: "No." }, | |
| 626 | as("u27", "stranger"), | |
| 627 | ) | |
| 628 | ).result.content[0].text, | |
| 629 | ).toContain("Only the proposer edits a proposal."); | |
| 630 | // An edit made by another process on the same file reaches an open page | |
| 631 | // the same way a new proposal does; `live.test.ts` is where that is proven. | |
| 632 | expect(await (await fetch(`${base}/p/${p.id}`)).text()).toContain( | |
| 633 | "Where do we meet", | |
| 634 | ); | |
| 635 | }); | |
| 636 | ||
| 445 | 637 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| 446 | 638 | const hello = await rpc({ |
| 447 | 639 | jsonrpc: "2.0", |
| @@ -455,6 +647,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 455 | 647 | "list_proposals", |
| 456 | 648 | "get_proposal", |
| 457 | 649 | "propose", |
| 650 | "edit_proposal", | |
| 458 | 651 | "withdraw_proposal", |
| 459 | 652 | "list_remarks", |
| 460 | 653 | "add_remark", |
src/tty.ts +115 −4
| @@ -3,7 +3,8 @@ | ||
| 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 | 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. | |
| 6 | * proposes, c says something, e corrects the text of one you opened, w | |
| 7 | * withdraws, Esc goes back and q leaves. | |
| 7 | 8 | * Plain ANSI, no dependencies. |
| 8 | 9 | * |
| 9 | 10 | * Nothing anyone wrote reaches this screen as a control character: what |
| @@ -14,6 +15,8 @@ | ||
| 14 | 15 | import { |
| 15 | 16 | addRemark, |
| 16 | 17 | canWithdraw, |
| 18 | editProposal, | |
| 19 | editRefusal, | |
| 17 | 20 | getProposal, |
| 18 | 21 | listProposals, |
| 19 | 22 | listRemarks, |
| @@ -62,10 +65,25 @@ interface RemarkDraft { | ||
| 62 | 65 | typing: string; |
| 63 | 66 | } |
| 64 | 67 | |
| 68 | /** | |
| 69 | * A proposal being corrected: the title, then the paragraph, each starting | |
| 70 | * as what is there now. `was` is that text, kept to say afterwards whether | |
| 71 | * anything actually changed. The options are not a step — they are not | |
| 72 | * editable at this door either. | |
| 73 | */ | |
| 74 | interface EditDraft { | |
| 75 | step: "title" | "body"; | |
| 76 | id: number; | |
| 77 | was: { title: string; body: string }; | |
| 78 | title: string; | |
| 79 | typing: string; | |
| 80 | } | |
| 81 | ||
| 65 | 82 | let cursor = 0; |
| 66 | 83 | let open: number | undefined; |
| 67 | 84 | let draft: Draft | undefined; |
| 68 | 85 | let remarkDraft: RemarkDraft | undefined; |
| 86 | let editDraft: EditDraft | undefined; | |
| 69 | 87 | let said = ""; |
| 70 | 88 | |
| 71 | 89 | const proposals = (): ProposalSummary[] => { |
| @@ -111,6 +129,7 @@ function draw(): void { | ||
| 111 | 129 | ]; |
| 112 | 130 | if (draft !== undefined) lines.push(...composer(draft)); |
| 113 | 131 | else if (remarkDraft !== undefined) lines.push(...remarkComposer(remarkDraft)); |
| 132 | else if (editDraft !== undefined) lines.push(...editComposer(editDraft)); | |
| 114 | 133 | else if (open !== undefined) lines.push(...detail(open)); |
| 115 | 134 | else lines.push(...list()); |
| 116 | 135 | lines.push(""); |
| @@ -141,7 +160,12 @@ const detail = (id: number): string[] => { | ||
| 141 | 160 | return [ |
| 142 | 161 | bold(oneLine(proposal.title)), |
| 143 | 162 | dim( |
| 144 | `${oneLine(proposal.author_name)} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`, | |
| 163 | `${oneLine(proposal.author_name)} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}${ | |
| 164 | // A correction is shown here as it is on the page: never silently. | |
| 165 | proposal.edited_at === null | |
| 166 | ? "" | |
| 167 | : ` · edited ${proposal.edited_at.slice(0, 16).replace("T", " ")}` | |
| 168 | }`, | |
| 145 | 169 | ), |
| 146 | 170 | "", |
| 147 | 171 | ...wrap(proposal.body), |
| @@ -256,16 +280,37 @@ const remarkComposer = (d: RemarkDraft): string[] => { | ||
| 256 | 280 | return lines; |
| 257 | 281 | }; |
| 258 | 282 | |
| 283 | /** | |
| 284 | * The text as it stands, to be corrected: the title first, then the | |
| 285 | * paragraph, each arriving already typed. What is on the screen has been | |
| 286 | * through `plain.ts` like everything else somebody else wrote — the buffer | |
| 287 | * keeps the real characters, the terminal is shown safe ones. | |
| 288 | */ | |
| 289 | const editComposer = (d: EditDraft): string[] => { | |
| 290 | const cell = `${esc}7m ${esc}0m`; | |
| 291 | const lines = [bold("Correcting a proposal"), ""]; | |
| 292 | lines.push( | |
| 293 | d.step === "title" | |
| 294 | ? `Title: ${oneLine(d.typing)}${cell}` | |
| 295 | : dim(`Title: ${oneLine(d.title)}`), | |
| 296 | ); | |
| 297 | if (d.step === "body") lines.push(`Paragraph: ${oneLine(d.typing)}${cell}`); | |
| 298 | lines.push(""); | |
| 299 | lines.push(dim("The options are not editable; a counter adds one.")); | |
| 300 | return lines; | |
| 301 | }; | |
| 302 | ||
| 259 | 303 | const keys = (): string => { |
| 260 | 304 | if (draft !== undefined) return "Enter accepts · Esc cancels"; |
| 261 | 305 | if (remarkDraft !== undefined) return "Enter accepts · Esc cancels"; |
| 306 | if (editDraft !== undefined) return "Enter accepts · Esc cancels"; | |
| 262 | 307 | if (open !== undefined) |
| 263 | 308 | return me.role === "anon" |
| 264 | 309 | ? "Esc back · q quit · sign in through the forge to say something" |
| 265 | : "Esc back · c say something · w withdraw · q quit"; | |
| 310 | : "Esc back · c say something · e correct · w withdraw · q quit"; | |
| 266 | 311 | return me.role === "anon" |
| 267 | 312 | ? "j/k move · Enter open · q quit · sign in through the forge to propose" |
| 268 | : "j/k move · Enter open · n propose · w withdraw · q quit"; | |
| 313 | : "j/k move · Enter open · n propose · e correct · w withdraw · q quit"; | |
| 269 | 314 | }; |
| 270 | 315 | |
| 271 | 316 | /** The draft, finished: the same action every other door calls. */ |
| @@ -311,6 +356,51 @@ function say(d: RemarkDraft, optionLabel: string): void { | ||
| 311 | 356 | } |
| 312 | 357 | } |
| 313 | 358 | |
| 359 | /** The correction, finished: the same action the page and the API call. */ | |
| 360 | function save(d: EditDraft, body: string): void { | |
| 361 | const saved = editProposal(db, me, { id: d.id, title: d.title, body }); | |
| 362 | if (saved.ok) { | |
| 363 | // The same words back is not a correction, and is not reported as one. | |
| 364 | said = | |
| 365 | saved.value.title === d.was.title && saved.value.body === d.was.body | |
| 366 | ? "Nothing changed." | |
| 367 | : "Corrected."; | |
| 368 | editDraft = undefined; | |
| 369 | open = d.id; | |
| 370 | } else { | |
| 371 | // Keep what was typed: the message says what to change. | |
| 372 | said = saved.message; | |
| 373 | d.step = "body"; | |
| 374 | d.typing = body; | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | /** The proposal under the cursor, opened for correction — if it may be. */ | |
| 379 | function correct(id: number | undefined): void { | |
| 380 | if (id === undefined) { | |
| 381 | said = "Nothing to correct."; | |
| 382 | return; | |
| 383 | } | |
| 384 | const proposal = one(id); | |
| 385 | if (proposal === undefined) { | |
| 386 | said = "No such proposal."; | |
| 387 | return; | |
| 388 | } | |
| 389 | // Exactly what the action would refuse with, asked before anything is typed. | |
| 390 | const refused = editRefusal(me, proposal); | |
| 391 | if (refused !== undefined) { | |
| 392 | said = refused.message; | |
| 393 | return; | |
| 394 | } | |
| 395 | editDraft = { | |
| 396 | step: "title", | |
| 397 | id, | |
| 398 | was: { title: proposal.title, body: proposal.body }, | |
| 399 | title: "", | |
| 400 | typing: proposal.title, | |
| 401 | }; | |
| 402 | } | |
| 403 | ||
| 314 | 404 | function withdraw(id: number | undefined): void { |
| 315 | 405 | if (id === undefined) { |
| 316 | 406 | said = "Nothing to withdraw."; |
| @@ -398,6 +488,26 @@ process.stdin.on("data", (key: string) => { | ||
| 398 | 488 | draw(); |
| 399 | 489 | return; |
| 400 | 490 | } |
| 491 | if (editDraft !== undefined) { | |
| 492 | const d = editDraft; | |
| 493 | if (key === "\x1b") { | |
| 494 | editDraft = undefined; | |
| 495 | said = "Cancelled."; | |
| 496 | } else if (key === "\x03") { | |
| 497 | editDraft = undefined; | |
| 498 | } else if (key === "\r") { | |
| 499 | if (d.step === "title") { | |
| 500 | d.title = d.typing.trim(); | |
| 501 | d.step = "body"; | |
| 502 | // The paragraph arrives already typed too: a typo is a keystroke | |
| 503 | // away, not a paragraph away. | |
| 504 | d.typing = d.was.body; | |
| 505 | } else save(d, d.typing.trim()); | |
| 506 | } else if (key === "\x7f") d.typing = d.typing.slice(0, -1); | |
| 507 | else if (key >= " ") d.typing += key; | |
| 508 | draw(); | |
| 509 | return; | |
| 510 | } | |
| 401 | 511 | if (key === "q" || key === "\x03") { |
| 402 | 512 | out(`${esc}2J${esc}H`); |
| 403 | 513 | process.exit(0); |
| @@ -440,6 +550,7 @@ process.stdin.on("data", (key: string) => { | ||
| 440 | 550 | typing: "", |
| 441 | 551 | }; |
| 442 | 552 | } |
| 553 | if (key === "e") correct(under); | |
| 443 | 554 | if (key === "w") withdraw(under); |
| 444 | 555 | draw(); |
| 445 | 556 | }); |