Public: anyone can read this; the crew can change it.
Propose, read and withdraw: a proposal with options replaces the note #12
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/2-propose-read-and-withdraw-a-proposal-wit → main 2 commits fork 1 thread · 1 unresolved
15 files
changed +1210
−342
README.md +31 −13
| @@ -23,13 +23,13 @@ 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` | list, add, delete — and who may | | |
| 26 | | `src/actions.ts` | propose, list, read, withdraw — and who may | | |
| 27 | 27 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 28 | 28 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 29 | 29 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 30 | 30 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 31 | 31 | | `src/routes.ts` | every path, in one table | |
| 32 | | `src/page.ts` | the HTML, rendered on the server | | |
| 32 | | `src/pages/` | the HTML, rendered on the server: one layout, two pages | | |
| 33 | 33 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 34 | 34 | | `src/server.ts` | boot: open the database, serve | |
| 35 | 35 | | `src/tty.ts` | the terminal, over the same actions | |
| @@ -42,9 +42,9 @@ The forge says who is asking on every request it proxies, and the app | ||
| 42 | 42 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: |
| 43 | 43 | |
| 44 | 44 | - **anon** — anyone. Reads. |
| 45 | - **user** — signed in to the forge and able to see this app. Adds notes, as | |
| 46 | themselves, and removes their own. | |
| 47 | - **admin** — able to write this repository. Removes any note. | |
| 45 | - **user** — signed in to the forge and able to see this app. Makes | |
| 46 | proposals, as themselves, and withdraws their own. | |
| 47 | - **admin** — able to write this repository. Withdraws any proposal. | |
| 48 | 48 | |
| 49 | 49 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 50 | 50 | `/__forge/signout`; the forge answers both on the app's own host. |
| @@ -54,14 +54,28 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 54 | 54 | The same actions, the same rules, at every door. A script is the person |
| 55 | 55 | whose forge grant it carries; a bare script is anonymous. |
| 56 | 56 | |
| 57 | In a browser, `/` is every open proposal — newest first, with its author, | |
| 58 | how many options it carries and where it stands — and the composer for | |
| 59 | whoever is signed in. `/p/<id>` is one proposal: the paragraph, the options | |
| 60 | in order, and a Withdraw form for its author or an admin while it is open. | |
| 61 | A guest sees both pages and no forms. | |
| 62 | ||
| 57 | 63 | sh |
| 58 | curl https://<app>/api/notes | |
| 59 | curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}' | |
| 60 | curl -X DELETE https://<app>/api/notes/1 | |
| 64 | curl https://<app>/api/proposals # every proposal, newest first | |
| 65 | curl 'https://<app>/api/proposals?state=open' | |
| 66 | curl https://<app>/api/proposals/1 # one, with its options in order | |
| 67 | curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \ | |
| 68 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"]}' | |
| 69 | curl -X POST https://<app>/api/proposals/1/withdraw | |
| 61 | 70 | |
| 62 | 71 | |
| 63 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 64 | `add_note`, `delete_note`: | |
| 72 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to | |
| 73 | eight options, each labelled, each different. Its author or an admin can | |
| 74 | withdraw it while it is open, once. `rule` says how it will be decided and | |
| 75 | is `majority` for now. | |
| 76 | ||
| 77 | As an MCP server, for Claude Code or Claude Desktop — `list_proposals`, | |
| 78 | `get_proposal`, `propose`, `withdraw_proposal`: | |
| 65 | 79 | |
| 66 | 80 | sh |
| 67 | 81 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
| @@ -73,14 +87,18 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp | ||
| 73 | 87 | } |
| 74 | 88 | |
| 75 | 89 | |
| 76 | The page stays current over `/events`: a note someone else adds or removes | |
| 77 | appears without a reload. Without JavaScript it is the same page, one | |
| 90 | The page stays current over `/events`: a proposal someone else opens or | |
| 91 | withdraws appears without a reload. Without JavaScript it is the same page, one | |
| 78 | 92 | reload behind. |
| 79 | 93 | |
| 80 | 94 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 81 | 95 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 82 | 96 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 83 | a real pty over the same data, as the account at the keyboard. | |
| 97 | a real pty over the same data, as the account at the keyboard: `j`/`k` | |
| 98 | moves, Enter opens the proposal under the cursor, `n` proposes (a title, | |
| 99 | then the paragraph, then options one per line until a blank line), `w` | |
| 100 | withdraws, Esc goes back and `q` quits. A guest is told to sign in rather | |
| 101 | than shown a composer. | |
| 84 | 102 | |
| 85 | 103 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 86 | 104 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
src/actions.test.ts +130 −29
| @@ -2,7 +2,13 @@ import { 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 { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 5 | import { | |
| 6 | canWithdraw, | |
| 7 | getProposal, | |
| 8 | listProposals, | |
| 9 | propose, | |
| 10 | withdrawProposal, | |
| 11 | } from "./actions.ts"; | |
| 6 | 12 | import { openDb } from "./db.ts"; |
| 7 | 13 | import type { Who } from "./who.ts"; |
| 8 | 14 | |
| @@ -12,47 +18,142 @@ const alice: Who = { role: "user", id: "u1", name: "alice" }; | ||
| 12 | 18 | const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| 13 | 19 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 14 | 20 | |
| 15 | test("anyone reads; only a signed-in person adds, as themselves", () => { | |
| 16 | expect(listNotes(db, anon)).toEqual({ ok: true, value: [] }); | |
| 17 | const refused = addNote(db, anon, { body: "hello" }); | |
| 21 | const good = { | |
| 22 | title: "Where do we meet", | |
| 23 | body: "We need one room for the retrospective.", | |
| 24 | options: ["The kitchen", "The garden"], | |
| 25 | }; | |
| 26 | const made = (who: Who = alice, input: Record<string, unknown> = {}) => { | |
| 27 | const r = propose(db, who, { ...good, ...input } as typeof good); | |
| 28 | if (!r.ok) throw new Error(`${r.error}: ${r.message}`); | |
| 29 | return r.value; | |
| 30 | }; | |
| 31 | ||
| 32 | test("anyone reads; only a signed-in person proposes, as themselves", () => { | |
| 33 | expect(listProposals(db, anon)).toEqual({ ok: true, value: [] }); | |
| 34 | const refused = propose(db, anon, good); | |
| 18 | 35 | expect(refused.ok).toBe(false); |
| 19 | 36 | if (!refused.ok) expect(refused.error).toBe("signin_required"); |
| 20 | const added = addNote(db, alice, { body: " hello " }); | |
| 21 | expect(added.ok).toBe(true); | |
| 22 | if (!added.ok) return; | |
| 23 | expect(added.value.body).toBe("hello"); | |
| 24 | expect(added.value.author_id).toBe("u1"); | |
| 25 | expect(added.value.author_name).toBe("alice"); | |
| 26 | const all = listNotes(db, anon); | |
| 27 | expect(all.ok && all.value.length).toBe(1); | |
| 37 | ||
| 38 | const opened = made(alice, { | |
| 39 | title: " Where do we meet ", | |
| 40 | body: " We need one room. ", | |
| 41 | options: [" The kitchen ", "The garden"], | |
| 42 | }); | |
| 43 | expect(opened.title).toBe("Where do we meet"); | |
| 44 | expect(opened.body).toBe("We need one room."); | |
| 45 | expect(opened.options.map((o) => o.label)).toEqual([ | |
| 46 | "The kitchen", | |
| 47 | "The garden", | |
| 48 | ]); | |
| 49 | expect(opened.options.map((o) => o.position)).toEqual([0, 1]); | |
| 50 | expect(opened.author_name).toBe("alice"); | |
| 51 | expect(opened.state).toBe("open"); | |
| 52 | expect(opened.rule).toBe("majority"); | |
| 53 | expect(opened.decided_option_id).toBeNull(); | |
| 54 | ||
| 55 | const listed = listProposals(db, anon, { state: "open" }); | |
| 56 | expect(listed.ok && listed.value[0]?.id).toBe(opened.id); | |
| 57 | expect(listed.ok && listed.value[0]?.option_count).toBe(2); | |
| 58 | ||
| 59 | const read = getProposal(db, anon, { id: opened.id }); | |
| 60 | expect(read.ok && read.value.options.length).toBe(2); | |
| 61 | const nothing = getProposal(db, anon, { id: 9999 }); | |
| 62 | expect(nothing.ok).toBe(false); | |
| 63 | if (!nothing.ok) expect(nothing.error).toBe("not_found"); | |
| 28 | 64 | }); |
| 29 | 65 | |
| 30 | test("a note is one to five hundred characters", () => { | |
| 31 | for (const body of ["", " ", "x".repeat(501)]) { | |
| 32 | const r = addNote(db, alice, { body }); | |
| 33 | expect(r.ok).toBe(false); | |
| 66 | test("a proposal has two to eight distinct, labelled options", () => { | |
| 67 | const bad: [string, unknown[]][] = [ | |
| 68 | ["one option", ["Only this"]], | |
| 69 | ["nine options", Array.from({ length: 9 }, (_, i) => `Option ${i}`)], | |
| 70 | ["a duplicate label", ["Yes", "yes"]], | |
| 71 | ["a blank label", ["Yes", " "]], | |
| 72 | ["a label of eighty-one characters", ["Yes", "x".repeat(81)]], | |
| 73 | ["nothing at all", []], | |
| 74 | ]; | |
| 75 | for (const [what, options] of bad) { | |
| 76 | const r = propose(db, alice, { ...good, options } as typeof good); | |
| 77 | expect(r.ok, what).toBe(false); | |
| 78 | if (!r.ok) { | |
| 79 | expect(r.error).toBe("invalid"); | |
| 80 | expect(r.message.length).toBeGreaterThan(10); | |
| 81 | } | |
| 82 | } | |
| 83 | const eight = made(alice, { | |
| 84 | options: Array.from({ length: 8 }, (_, i) => `O${i}`), | |
| 85 | }); | |
| 86 | expect(eight.options.length).toBe(8); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a title is at most a hundred and twenty characters, a paragraph two thousand", () => { | |
| 90 | for (const input of [ | |
| 91 | { title: "" }, | |
| 92 | { title: "x".repeat(121) }, | |
| 93 | { body: " " }, | |
| 94 | { body: "x".repeat(2001) }, | |
| 95 | { rule: "whatever-i-like" }, | |
| 96 | ]) { | |
| 97 | const r = propose(db, alice, { ...good, ...input } as typeof good); | |
| 98 | expect(r.ok, JSON.stringify(input).slice(0, 40)).toBe(false); | |
| 34 | 99 | if (!r.ok) expect(r.error).toBe("invalid"); |
| 35 | 100 | } |
| 101 | expect(made(alice, { body: "x".repeat(2000) }).body.length).toBe(2000); | |
| 36 | 102 | }); |
| 37 | 103 | |
| 38 | test("only the author or an admin removes a note", () => { | |
| 39 | const mine = addNote(db, alice, { body: "mine" }); | |
| 40 | if (!mine.ok) throw new Error(mine.message); | |
| 41 | const byBob = deleteNote(db, bob, { id: mine.value.id }); | |
| 104 | test("only the author or an admin withdraws, and only once", () => { | |
| 105 | const mine = made(alice); | |
| 106 | expect(canWithdraw(alice, mine)).toBe(true); | |
| 107 | expect(canWithdraw(bob, mine)).toBe(false); | |
| 108 | expect(canWithdraw(root, mine)).toBe(true); | |
| 109 | expect(canWithdraw(anon, mine)).toBe(false); | |
| 110 | ||
| 111 | const byBob = withdrawProposal(db, bob, { id: mine.id }); | |
| 42 | 112 | expect(byBob.ok).toBe(false); |
| 43 | 113 | if (!byBob.ok) expect(byBob.error).toBe("forbidden"); |
| 44 | const byAnon = deleteNote(db, anon, { id: mine.value.id }); | |
| 114 | const byAnon = withdrawProposal(db, anon, { id: mine.id }); | |
| 45 | 115 | expect(byAnon.ok).toBe(false); |
| 46 | 116 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); |
| 47 | expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true); | |
| 48 | const gone = deleteNote(db, alice, { id: mine.value.id }); | |
| 49 | expect(gone.ok).toBe(false); | |
| 50 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 51 | 117 | |
| 52 | const bobs = addNote(db, bob, { body: "bob's" }); | |
| 53 | if (!bobs.ok) throw new Error(bobs.message); | |
| 54 | expect(deleteNote(db, root, { id: bobs.value.id }).ok).toBe(true); | |
| 55 | const odd = deleteNote(db, root, { id: 1.5 }); | |
| 118 | const gone = withdrawProposal(db, alice, { id: mine.id }); | |
| 119 | expect(gone.ok && gone.value.state).toBe("withdrawn"); | |
| 120 | const again = withdrawProposal(db, alice, { id: mine.id }); | |
| 121 | expect(again.ok).toBe(false); | |
| 122 | if (!again.ok) expect(again.error).toBe("invalid"); | |
| 123 | ||
| 124 | // A withdrawn proposal is still readable, and no longer open. | |
| 125 | const openOnes = listProposals(db, anon, { state: "open" }); | |
| 126 | expect(openOnes.ok && openOnes.value.map((p) => p.id)).not.toContain(mine.id); | |
| 127 | expect(getProposal(db, anon, { id: mine.id }).ok).toBe(true); | |
| 128 | ||
| 129 | const bobs = made(bob); | |
| 130 | expect(withdrawProposal(db, root, { id: bobs.id }).ok).toBe(true); | |
| 131 | ||
| 132 | const odd = withdrawProposal(db, root, { id: 1.5 }); | |
| 56 | 133 | expect(odd.ok).toBe(false); |
| 57 | 134 | if (!odd.ok) expect(odd.error).toBe("invalid"); |
| 135 | const nowhere = withdrawProposal(db, root, { id: 9999 }); | |
| 136 | expect(nowhere.ok).toBe(false); | |
| 137 | if (!nowhere.ok) expect(nowhere.error).toBe("not_found"); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a proposal refused writes nothing at all", () => { | |
| 141 | const before = listProposals(db, anon); | |
| 142 | const refused = propose(db, alice, { | |
| 143 | ...good, | |
| 144 | title: "x".repeat(200), | |
| 145 | }); | |
| 146 | expect(refused.ok).toBe(false); | |
| 147 | const after = listProposals(db, anon); | |
| 148 | expect(after.ok && after.value.length).toBe( | |
| 149 | before.ok ? before.value.length : -1, | |
| 150 | ); | |
| 151 | }); | |
| 152 | ||
| 153 | test("a state that is not a state is refused, and a limit is bounded", () => { | |
| 154 | const bad = listProposals(db, anon, { state: "argued" }); | |
| 155 | expect(bad.ok).toBe(false); | |
| 156 | if (!bad.ok) expect(bad.error).toBe("invalid"); | |
| 157 | const capped = listProposals(db, anon, { limit: 1 }); | |
| 158 | expect(capped.ok && capped.value.length).toBe(1); | |
| 58 | 159 | }); |
src/actions.ts +165 −39
| @@ -10,15 +10,38 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { emit } from "./live.ts"; |
| 11 | 11 | import type { Who } from "./who.ts"; |
| 12 | 12 | |
| 13 | export interface Note { | |
| 13 | export type State = "open" | "decided" | "withdrawn"; | |
| 14 | ||
| 15 | export interface Proposal { | |
| 14 | 16 | readonly id: number; |
| 17 | readonly title: string; | |
| 15 | 18 | readonly body: string; |
| 19 | readonly rule: string; | |
| 20 | readonly state: State; | |
| 21 | readonly decided_option_id: number | null; | |
| 16 | 22 | readonly author_id: string; |
| 17 | 23 | readonly author_name: string; |
| 18 | 24 | readonly created_at: string; |
| 19 | 25 | readonly updated_at: string; |
| 20 | 26 | } |
| 21 | 27 | |
| 28 | export interface Option { | |
| 29 | readonly id: number; | |
| 30 | readonly proposal_id: number; | |
| 31 | readonly label: string; | |
| 32 | readonly position: number; | |
| 33 | } | |
| 34 | ||
| 35 | /** A proposal in a list: the row, plus how many options it carries. */ | |
| 36 | export interface ProposalSummary extends Proposal { | |
| 37 | readonly option_count: number; | |
| 38 | } | |
| 39 | ||
| 40 | /** A proposal on its own page: the row with its options, in order. */ | |
| 41 | export interface FullProposal extends Proposal { | |
| 42 | readonly options: readonly Option[]; | |
| 43 | } | |
| 44 | ||
| 22 | 45 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 23 | 46 | export type Result<T> = |
| 24 | 47 | | { ok: true; value: T } |
| @@ -30,64 +53,167 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({ | ||
| 30 | 53 | message, |
| 31 | 54 | }); |
| 32 | 55 | |
| 33 | export const BODY_MAX = 500; | |
| 56 | export const TITLE_MAX = 120; | |
| 57 | export const BODY_MAX = 2000; | |
| 58 | export const LABEL_MAX = 80; | |
| 59 | export const OPTIONS_MIN = 2; | |
| 60 | export const OPTIONS_MAX = 8; | |
| 61 | export const STATES: readonly State[] = ["open", "decided", "withdrawn"]; | |
| 62 | /** How a proposal is decided. Only one rule exists so far; the rules module adds the rest. */ | |
| 63 | export const RULES: readonly string[] = ["majority"]; | |
| 34 | 64 | |
| 35 | export function listNotes( | |
| 65 | const SUMMARY = | |
| 66 | "SELECT p.*, (SELECT count(*) FROM option WHERE proposal_id = p.id) AS option_count FROM proposal p"; | |
| 67 | ||
| 68 | export function listProposals( | |
| 36 | 69 | db: Database, |
| 37 | 70 | _who: Who, |
| 38 | input: { limit?: number } = {}, | |
| 39 | ): Result<Note[]> { | |
| 71 | input: { state?: string | undefined; limit?: number | undefined } = {}, | |
| 72 | ): Result<ProposalSummary[]> { | |
| 40 | 73 | const limit = Math.min( |
| 41 | 74 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 42 | 75 | 500, |
| 43 | 76 | ); |
| 77 | const state = input.state; | |
| 78 | if (state !== undefined && !STATES.includes(state as State)) | |
| 79 | return fail("invalid", `A state is one of ${STATES.join(", ")}.`); | |
| 44 | 80 | return ok( |
| 45 | db | |
| 46 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") | |
| 47 | .all(limit), | |
| 81 | state === undefined | |
| 82 | ? db | |
| 83 | .query< | |
| 84 | ProposalSummary, | |
| 85 | [number] | |
| 86 | >(`${SUMMARY} ORDER BY p.id DESC LIMIT ?1`) | |
| 87 | .all(limit) | |
| 88 | : db | |
| 89 | .query< | |
| 90 | ProposalSummary, | |
| 91 | [string, number] | |
| 92 | >(`${SUMMARY} WHERE p.state = ?1 ORDER BY p.id DESC LIMIT ?2`) | |
| 93 | .all(state, limit), | |
| 48 | 94 | ); |
| 49 | 95 | } |
| 50 | 96 | |
| 51 | export function addNote( | |
| 97 | export function getProposal( | |
| 52 | 98 | db: Database, |
| 53 | who: Who, | |
| 54 | input: { body: string }, | |
| 55 | ): Result<Note> { | |
| 56 | if (who.role === "anon") | |
| 57 | return fail("signin_required", "Sign in to add a note."); | |
| 58 | const body = input.body.trim(); | |
| 59 | if (body.length === 0 || body.length > BODY_MAX) | |
| 60 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); | |
| 61 | const note = db | |
| 99 | _who: Who, | |
| 100 | input: { id: number }, | |
| 101 | ): Result<FullProposal> { | |
| 102 | if (!Number.isInteger(input.id)) | |
| 103 | return fail("invalid", "A proposal id is a whole number."); | |
| 104 | const proposal = db | |
| 105 | .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 106 | .get(input.id); | |
| 107 | if (proposal === null) return fail("not_found", "No such proposal."); | |
| 108 | const options = db | |
| 62 | 109 | .query< |
| 63 | Note, | |
| 64 | [string, string, string] | |
| 65 | >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") | |
| 66 | .get(body, who.id, who.name)!; | |
| 67 | emit("note.added", note); | |
| 68 | return ok(note); | |
| 110 | Option, | |
| 111 | [number] | |
| 112 | >("SELECT * FROM option WHERE proposal_id = ?1 ORDER BY position") | |
| 113 | .all(proposal.id); | |
| 114 | return ok({ ...proposal, options }); | |
| 69 | 115 | } |
| 70 | 116 | |
| 71 | export function deleteNote( | |
| 117 | export function propose( | |
| 118 | db: Database, | |
| 119 | who: Who, | |
| 120 | input: { | |
| 121 | title: string; | |
| 122 | body: string; | |
| 123 | options: readonly unknown[]; | |
| 124 | rule?: string | undefined; | |
| 125 | }, | |
| 126 | ): Result<FullProposal> { | |
| 127 | if (who.role === "anon") | |
| 128 | return fail("signin_required", "Sign in to make a proposal."); | |
| 129 | const title = String(input.title ?? "").trim(); | |
| 130 | if (title.length === 0 || title.length > TITLE_MAX) | |
| 131 | return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); | |
| 132 | const body = String(input.body ?? "").trim(); | |
| 133 | if (body.length === 0 || body.length > BODY_MAX) | |
| 134 | return fail("invalid", `A paragraph is 1 to ${BODY_MAX} characters.`); | |
| 135 | const rule = String(input.rule ?? "majority").trim() || "majority"; | |
| 136 | if (!RULES.includes(rule)) | |
| 137 | return fail("invalid", `A rule is one of ${RULES.join(", ")}.`); | |
| 138 | if (!Array.isArray(input.options)) | |
| 139 | return fail( | |
| 140 | "invalid", | |
| 141 | `A proposal has ${OPTIONS_MIN} to ${OPTIONS_MAX} options, one per line.`, | |
| 142 | ); | |
| 143 | const labels = input.options.map((label) => String(label ?? "").trim()); | |
| 144 | if (labels.length < OPTIONS_MIN || labels.length > OPTIONS_MAX) | |
| 145 | return fail( | |
| 146 | "invalid", | |
| 147 | `A proposal has ${OPTIONS_MIN} to ${OPTIONS_MAX} options; this one has ${labels.length}.`, | |
| 148 | ); | |
| 149 | if (labels.some((label) => label.length === 0)) | |
| 150 | return fail("invalid", "Every option needs a label."); | |
| 151 | if (labels.some((label) => label.length > LABEL_MAX)) | |
| 152 | return fail( | |
| 153 | "invalid", | |
| 154 | `An option label is at most ${LABEL_MAX} characters.`, | |
| 155 | ); | |
| 156 | const seen = new Set(labels.map((label) => label.toLowerCase())); | |
| 157 | if (seen.size !== labels.length) | |
| 158 | return fail("invalid", "Two options cannot carry the same label."); | |
| 159 | ||
| 160 | // The proposal and its options are one thing: both rows or neither. | |
| 161 | const created = db.transaction((): FullProposal => { | |
| 162 | const proposal = db | |
| 163 | .query< | |
| 164 | Proposal, | |
| 165 | [string, string, string, string, string] | |
| 166 | >("INSERT INTO proposal (title, body, rule, author_id, author_name) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *") | |
| 167 | .get(title, body, rule, who.id, who.name)!; | |
| 168 | const insert = db.query< | |
| 169 | Option, | |
| 170 | [number, string, number] | |
| 171 | >("INSERT INTO option (proposal_id, label, position) VALUES (?1, ?2, ?3) RETURNING *"); | |
| 172 | const options = labels.map((label, i) => insert.get(proposal.id, label, i)!); | |
| 173 | return { ...proposal, options }; | |
| 174 | })(); | |
| 175 | emit("proposal.opened", created); | |
| 176 | return ok(created); | |
| 177 | } | |
| 178 | ||
| 179 | export function withdrawProposal( | |
| 72 | 180 | db: Database, |
| 73 | 181 | who: Who, |
| 74 | 182 | input: { id: number }, |
| 75 | ): Result<{ id: number }> { | |
| 183 | ): Result<Proposal> { | |
| 76 | 184 | if (who.role === "anon") |
| 77 | return fail("signin_required", "Sign in to remove a note."); | |
| 185 | return fail("signin_required", "Sign in to withdraw a proposal."); | |
| 78 | 186 | if (!Number.isInteger(input.id)) |
| 79 | return fail("invalid", "A note id is a whole number."); | |
| 80 | const note = db | |
| 81 | .query<Note, [number]>("SELECT * FROM note WHERE id = ?1") | |
| 187 | return fail("invalid", "A proposal id is a whole number."); | |
| 188 | const proposal = db | |
| 189 | .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 82 | 190 | .get(input.id); |
| 83 | if (note === null) return fail("not_found", "No such note."); | |
| 84 | if (!canDelete(who, note)) | |
| 85 | return fail("forbidden", "Only the author or an admin can remove a note."); | |
| 86 | db.query("DELETE FROM note WHERE id = ?1").run(note.id); | |
| 87 | emit("note.removed", { id: note.id }); | |
| 88 | return ok({ id: note.id }); | |
| 191 | if (proposal === null) return fail("not_found", "No such proposal."); | |
| 192 | if (!canWithdraw(who, proposal)) | |
| 193 | return fail( | |
| 194 | "forbidden", | |
| 195 | "Only the author or an admin can withdraw a proposal.", | |
| 196 | ); | |
| 197 | if (proposal.state !== "open") | |
| 198 | return fail( | |
| 199 | "invalid", | |
| 200 | `This proposal is already ${proposal.state}; only an open one can be withdrawn.`, | |
| 201 | ); | |
| 202 | const withdrawn = db | |
| 203 | .query< | |
| 204 | Proposal, | |
| 205 | [number] | |
| 206 | >("UPDATE proposal SET state = 'withdrawn', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *") | |
| 207 | .get(proposal.id)!; | |
| 208 | emit("proposal.withdrawn", { id: withdrawn.id }); | |
| 209 | return ok(withdrawn); | |
| 89 | 210 | } |
| 90 | 211 | |
| 91 | /** Whether `who` may remove `note`. The page and the terminal draw from this, so the rule lives once. */ | |
| 92 | export const canDelete = (who: Who, note: Note): boolean => | |
| 93 | who.role === "admin" || (who.role === "user" && who.id === note.author_id); | |
| 212 | /** | |
| 213 | * Whether `who` may withdraw `proposal` at all. The state is the action's | |
| 214 | * own check; the page and the terminal ask this and the state together, so | |
| 215 | * the rule about who lives once. | |
| 216 | */ | |
| 217 | export const canWithdraw = (who: Who, proposal: Proposal): boolean => | |
| 218 | who.role === "admin" || | |
| 219 | (who.role === "user" && who.id === proposal.author_id); |
src/db.test.ts +63
| @@ -1,3 +1,4 @@ | ||
| 1 | import { Database } from "bun:sqlite"; | |
| 1 | 2 | import { expect, test } from "bun:test"; |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| @@ -21,3 +22,65 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 21 | 22 | ?.foreign_keys, |
| 22 | 23 | ).toBe(1); |
| 23 | 24 | }); |
| 25 | ||
| 26 | test("a database that stopped at the note migrates forward", () => { | |
| 27 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); | |
| 28 | const old = new Database(join(dir, "app.db")); | |
| 29 | old.exec( | |
| 30 | "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))", | |
| 31 | ); | |
| 32 | migrations.slice(0, 2).forEach((sql, i) => { | |
| 33 | old.exec(sql); | |
| 34 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 35 | }); | |
| 36 | old.query( | |
| 37 | "INSERT INTO note (body, author_id, author_name) VALUES ('history', 'u1', 'alice')", | |
| 38 | ).run(); | |
| 39 | old.close(); | |
| 40 | ||
| 41 | const db = openDb(dir); | |
| 42 | expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" }); | |
| 43 | expect(() => db.query("SELECT 1 FROM note").get()).toThrow(); | |
| 44 | expect(() => db.query("SELECT 1 FROM proposal").get()).not.toThrow(); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a proposal has options that go with it, and the template's note is gone", () => { | |
| 48 | const db = openDb(mkdtempSync(join(tmpdir(), "app-db-"))); | |
| 49 | const names = db | |
| 50 | .query< | |
| 51 | { name: string }, | |
| 52 | [] | |
| 53 | >("SELECT name FROM sqlite_master WHERE type IN ('table', 'index') ORDER BY name") | |
| 54 | .all() | |
| 55 | .map((row) => row.name); | |
| 56 | expect(names).toContain("proposal"); | |
| 57 | expect(names).toContain("option"); | |
| 58 | expect(names).toContain("option_proposal"); | |
| 59 | expect(names).not.toContain("note"); | |
| 60 | ||
| 61 | // An option belongs to a proposal and leaves with it. | |
| 62 | db.query( | |
| 63 | "INSERT INTO proposal (title, body, author_id, author_name) VALUES ('t', 'b', 'u1', 'alice')", | |
| 64 | ).run(); | |
| 65 | db.query( | |
| 66 | "INSERT INTO option (proposal_id, label, position) VALUES (1, 'yes', 0)", | |
| 67 | ).run(); | |
| 68 | expect(() => | |
| 69 | db | |
| 70 | .query( | |
| 71 | "INSERT INTO option (proposal_id, label, position) VALUES (99, 'nowhere', 0)", | |
| 72 | ) | |
| 73 | .run(), | |
| 74 | ).toThrow(); | |
| 75 | db.query("DELETE FROM proposal WHERE id = 1").run(); | |
| 76 | expect( | |
| 77 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM option").get()?.n, | |
| 78 | ).toBe(0); | |
| 79 | expect(() => | |
| 80 | db | |
| 81 | .query( | |
| 82 | "INSERT INTO proposal (title, body, state, author_id, author_name) VALUES ('t', 'b', 'arguing', 'u1', 'alice')", | |
| 83 | ) | |
| 84 | .run(), | |
| 85 | ).toThrow(); | |
| 86 | }); |
src/db.ts +25
| @@ -24,6 +24,31 @@ export const migrations: string[] = [ | ||
| 24 | 24 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 25 | 25 | )`, |
| 26 | 26 | `CREATE INDEX note_author ON note (author_id)`, |
| 27 | // A proposal is a title, a paragraph and two to eight options; the rule | |
| 28 | // says how it will be decided, the state is where it is in its life, and | |
| 29 | // decided_option_id is the option that won once one has. | |
| 30 | `CREATE TABLE proposal ( | |
| 31 | id INTEGER PRIMARY KEY, | |
| 32 | title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 120), | |
| 33 | body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), | |
| 34 | rule TEXT NOT NULL DEFAULT 'majority', | |
| 35 | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'decided', 'withdrawn')), | |
| 36 | decided_option_id INTEGER REFERENCES option (id), | |
| 37 | author_id TEXT NOT NULL, | |
| 38 | author_name TEXT NOT NULL, | |
| 39 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 40 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 41 | ); | |
| 42 | CREATE TABLE option ( | |
| 43 | id INTEGER PRIMARY KEY, | |
| 44 | proposal_id INTEGER NOT NULL REFERENCES proposal (id) ON DELETE CASCADE, | |
| 45 | label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 80), | |
| 46 | position INTEGER NOT NULL | |
| 47 | ); | |
| 48 | CREATE INDEX option_proposal ON option (proposal_id)`, | |
| 49 | // The template's note is gone: a proposal is what this app is about. | |
| 50 | // Migration 1 stays exactly as it ran; this is how history is undone. | |
| 51 | `DROP TABLE note`, | |
| 27 | 52 | ]; |
| 28 | 53 | |
| 29 | 54 | export function openDb(dir = dataDir): Database { |
src/http.ts +8 −4
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The floor every response stands on, and the few things a route needs to |
| 3 | * read a request safely. Nothing in here knows what a note is. | |
| 3 | * read a request safely. Nothing in here knows what a proposal is. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createHash } from "node:crypto"; |
| @@ -58,12 +58,16 @@ export const text = (body: string, status = 200): Response => | ||
| 58 | 58 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | /** Back to the page, with a word for it to show once (`readNotice`). */ | |
| 62 | export const back = (notice?: string): Response => | |
| 61 | /** | |
| 62 | * Back to a page of this app, with a word for it to show once | |
| 63 | * (`readNotice`). The location is the app's own — a path a route built, never | |
| 64 | * one a request carried — so no redirect here goes off this site. | |
| 65 | */ | |
| 66 | export const back = (notice?: string, location = "/"): Response => | |
| 63 | 67 | new Response(null, { |
| 64 | 68 | status: 303, |
| 65 | 69 | headers: { |
| 66 | location: "/", | |
| 70 | location, | |
| 67 | 71 | ...SECURITY, |
| 68 | 72 | ...(notice === undefined |
| 69 | 73 | ? {} |
src/mcp.ts +73 −19
| @@ -7,7 +7,20 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { addNote, deleteNote, listNotes, type Result } from "./actions.ts"; | |
| 10 | import { | |
| 11 | getProposal, | |
| 12 | listProposals, | |
| 13 | propose, | |
| 14 | withdrawProposal, | |
| 15 | BODY_MAX, | |
| 16 | LABEL_MAX, | |
| 17 | OPTIONS_MAX, | |
| 18 | OPTIONS_MIN, | |
| 19 | RULES, | |
| 20 | STATES, | |
| 21 | TITLE_MAX, | |
| 22 | type Result, | |
| 23 | } from "./actions.ts"; | |
| 11 | 24 | import { title } from "./env.ts"; |
| 12 | 25 | import { fields } from "./http.ts"; |
| 13 | 26 | import type { Who } from "./who.ts"; |
| @@ -25,11 +38,17 @@ interface Tool { | ||
| 25 | 38 | |
| 26 | 39 | export const tools: readonly Tool[] = [ |
| 27 | 40 | { |
| 28 | name: "list_notes", | |
| 29 | description: "The notes, newest first.", | |
| 41 | name: "list_proposals", | |
| 42 | description: | |
| 43 | "The proposals, newest first, optionally only those in one state.", | |
| 30 | 44 | inputSchema: { |
| 31 | 45 | type: "object", |
| 32 | 46 | properties: { |
| 47 | state: { | |
| 48 | type: "string", | |
| 49 | enum: [...STATES], | |
| 50 | description: "Only proposals in this state (all states by default).", | |
| 51 | }, | |
| 33 | 52 | limit: { |
| 34 | 53 | type: "integer", |
| 35 | 54 | minimum: 1, |
| @@ -39,28 +58,63 @@ export const tools: readonly Tool[] = [ | ||
| 39 | 58 | }, |
| 40 | 59 | }, |
| 41 | 60 | run: (db, who, args) => |
| 42 | listNotes(db, who, { limit: Number(args.limit ?? 100) }), | |
| 61 | listProposals(db, who, { | |
| 62 | state: args.state === undefined ? undefined : String(args.state), | |
| 63 | limit: Number(args.limit ?? 100), | |
| 64 | }), | |
| 43 | 65 | }, |
| 44 | 66 | { |
| 45 | name: "add_note", | |
| 46 | description: | |
| 47 | "Add a note as the signed-in person. Anonymous callers cannot.", | |
| 67 | name: "get_proposal", | |
| 68 | description: "One proposal by id, with its options in order.", | |
| 48 | 69 | inputSchema: { |
| 49 | 70 | type: "object", |
| 50 | properties: { body: { type: "string", maxLength: 500 } }, | |
| 51 | required: ["body"], | |
| 52 | }, | |
| 53 | run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), | |
| 54 | }, | |
| 55 | { | |
| 56 | name: "delete_note", | |
| 57 | description: "Remove a note by id. Only its author or an admin can.", | |
| 58 | inputSchema: { | |
| 59 | type: "object", | |
| 60 | properties: { id: { type: "integer" } }, | |
| 71 | properties: { id: { type: "integer", minimum: 1 } }, | |
| 61 | 72 | required: ["id"], |
| 62 | 73 | }, |
| 63 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), | |
| 74 | run: (db, who, args) => getProposal(db, who, { id: Number(args.id) }), | |
| 75 | }, | |
| 76 | { | |
| 77 | name: "propose", | |
| 78 | description: | |
| 79 | "Open a proposal as the signed-in person: a title, a paragraph and two to eight distinct options. Anonymous callers cannot.", | |
| 80 | inputSchema: { | |
| 81 | type: "object", | |
| 82 | properties: { | |
| 83 | title: { type: "string", minLength: 1, maxLength: TITLE_MAX }, | |
| 84 | body: { type: "string", minLength: 1, maxLength: BODY_MAX }, | |
| 85 | options: { | |
| 86 | type: "array", | |
| 87 | minItems: OPTIONS_MIN, | |
| 88 | maxItems: OPTIONS_MAX, | |
| 89 | items: { type: "string", minLength: 1, maxLength: LABEL_MAX }, | |
| 90 | description: "The options to decide between; no two the same.", | |
| 91 | }, | |
| 92 | rule: { | |
| 93 | type: "string", | |
| 94 | enum: [...RULES], | |
| 95 | description: "How it will be decided (majority).", | |
| 96 | }, | |
| 97 | }, | |
| 98 | required: ["title", "body", "options"], | |
| 99 | }, | |
| 100 | run: (db, who, args) => | |
| 101 | propose(db, who, { | |
| 102 | title: String(args.title ?? ""), | |
| 103 | body: String(args.body ?? ""), | |
| 104 | options: Array.isArray(args.options) ? args.options : [], | |
| 105 | rule: String(args.rule ?? "majority"), | |
| 106 | }), | |
| 107 | }, | |
| 108 | { | |
| 109 | name: "withdraw_proposal", | |
| 110 | description: | |
| 111 | "Withdraw an open proposal by id. Only its author or an admin can, and only while it is open.", | |
| 112 | inputSchema: { | |
| 113 | type: "object", | |
| 114 | properties: { id: { type: "integer", minimum: 1 } }, | |
| 115 | required: ["id"], | |
| 116 | }, | |
| 117 | run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }), | |
| 64 | 118 | }, |
| 65 | 119 | ]; |
| 66 | 120 |
src/page.ts deleted −96
| @@ -1,96 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The page, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. | |
| 5 | */ | |
| 6 | ||
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; | |
| 9 | import { asset, escape } from "./http.ts"; | |
| 10 | import type { Who } from "./who.ts"; | |
| 11 | ||
| 12 | export function layout(body: string, who: Who): string { | |
| 13 | const door = | |
| 14 | who.role === "anon" | |
| 15 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` | |
| 16 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 17 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 18 | return `<!doctype html> | |
| 19 | <html lang="en"> | |
| 20 | <meta charset="utf-8"> | |
| 21 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 22 | <meta name="color-scheme" content="light dark"> | |
| 23 | <link rel="icon" href="data:,"> | |
| 24 | <title>${escape(title)}</title> | |
| 25 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 26 | <script type="module" src="${asset("live.js")}"></script> | |
| 27 | <body> | |
| 28 | <div class="page"> | |
| 29 | <header class="head"> | |
| 30 | <div> | |
| 31 | <h1>${escape(title)}</h1> | |
| 32 | <p class="muted">${escape(description)}</p> | |
| 33 | </div> | |
| 34 | <nav class="row"> | |
| 35 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 36 | ${door} | |
| 37 | </nav> | |
| 38 | </header> | |
| 39 | <main>${body}</main> | |
| 40 | <footer class="muted"> | |
| 41 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 42 | <p>Also at <a href="/api/notes">/api/notes</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 43 | </footer> | |
| 44 | </div> | |
| 45 | </body> | |
| 46 | </html> | |
| 47 | `; | |
| 48 | } | |
| 49 | ||
| 50 | export function home(input: { | |
| 51 | who: Who; | |
| 52 | notes: readonly Note[]; | |
| 53 | notice?: string | undefined; | |
| 54 | }): string { | |
| 55 | const { who, notes, notice } = input; | |
| 56 | const composer = | |
| 57 | who.role === "anon" | |
| 58 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 59 | : `<form class="row" method="post" action="/notes"> | |
| 60 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 61 | <button class="button">Add</button> | |
| 62 | </form>`; | |
| 63 | const list = | |
| 64 | notes.length === 0 | |
| 65 | ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>` | |
| 66 | : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`; | |
| 67 | return layout( | |
| 68 | `<section class="card">${composer}</section> | |
| 69 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 70 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, | |
| 71 | who, | |
| 72 | ); | |
| 73 | } | |
| 74 | ||
| 75 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 76 | <div> | |
| 77 | <p>${escape(note.body)}</p> | |
| 78 | <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small> | |
| 79 | </div> | |
| 80 | ${ | |
| 81 | canDelete(who, note) | |
| 82 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 83 | : "" | |
| 84 | } | |
| 85 | </li>`; | |
| 86 | ||
| 87 | const ago = (iso: string): string => { | |
| 88 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 89 | if (s < 60) return "just now"; | |
| 90 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 91 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 92 | return new Date(iso).toLocaleDateString("en", { | |
| 93 | month: "short", | |
| 94 | day: "numeric", | |
| 95 | }); | |
| 96 | }; |
src/pages/home.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The front page: every open proposal, newest first, and the composer for | |
| 3 | * whoever is signed in. A guest sees the proposals and no forms. | |
| 4 | */ | |
| 5 | ||
| 6 | import { | |
| 7 | BODY_MAX, | |
| 8 | LABEL_MAX, | |
| 9 | OPTIONS_MAX, | |
| 10 | OPTIONS_MIN, | |
| 11 | TITLE_MAX, | |
| 12 | type ProposalSummary, | |
| 13 | } from "../actions.ts"; | |
| 14 | import { where } from "../env.ts"; | |
| 15 | import { escape } from "../http.ts"; | |
| 16 | import type { Who } from "../who.ts"; | |
| 17 | import { ago, layout, toast } from "./layout.ts"; | |
| 18 | ||
| 19 | export function home(input: { | |
| 20 | who: Who; | |
| 21 | proposals: readonly ProposalSummary[]; | |
| 22 | notice?: string | undefined; | |
| 23 | }): string { | |
| 24 | const { who, proposals, notice } = input; | |
| 25 | const composer = | |
| 26 | who.role === "anon" | |
| 27 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=%2F">Sign in</a> to make a proposal.</p>` | |
| 28 | : `<form class="stack" method="post" action="/proposals"> | |
| 29 | <label class="field"> | |
| 30 | <span>Title</span> | |
| 31 | <input class="input" name="title" placeholder="What is being decided" required maxlength="${TITLE_MAX}" autocomplete="off"> | |
| 32 | </label> | |
| 33 | <label class="field"> | |
| 34 | <span>The paragraph</span> | |
| 35 | <textarea class="input" name="body" rows="4" placeholder="Why this, and what it changes" required maxlength="${BODY_MAX}"></textarea> | |
| 36 | </label> | |
| 37 | <label class="field"> | |
| 38 | <span>Options, one per line</span> | |
| 39 | <textarea class="input" name="options" rows="3" placeholder="Yes No" required maxlength="${(LABEL_MAX + 1) * OPTIONS_MAX}"></textarea> | |
| 40 | </label> | |
| 41 | <p class="muted small">${OPTIONS_MIN} to ${OPTIONS_MAX} options, each at most ${LABEL_MAX} characters and each different.</p> | |
| 42 | <div class="row"><button class="button">Propose</button></div> | |
| 43 | </form>`; | |
| 44 | const list = | |
| 45 | proposals.length === 0 | |
| 46 | ? `<p class="empty">No proposals yet. What is written here stays in ${escape(where)} data.</p>` | |
| 47 | : `<ul class="list">${proposals.map(item).join("")}</ul>`; | |
| 48 | return layout( | |
| 49 | `<section class="card">${composer}</section> | |
| 50 | <section id="proposals" data-live="proposal.opened proposal.withdrawn">${list}</section> | |
| 51 | ${toast(notice)}`, | |
| 52 | who, | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | const item = (proposal: ProposalSummary): string => `<li class="proposal"> | |
| 57 | <div> | |
| 58 | <p><a class="title" href="/p/${proposal.id}">${escape(proposal.title)}</a></p> | |
| 59 | <small class="muted">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time> · ${proposal.option_count} option${proposal.option_count === 1 ? "" : "s"}</small> | |
| 60 | </div> | |
| 61 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> | |
| 62 | </li>`; |
src/pages/layout.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * The frame every page is drawn in, and the small helpers a page shares. | |
| 3 | * The first paint is complete HTML; `static/live.js` keeps the regions | |
| 4 | * marked `data-live` fresh afterwards. Each page has its own file here and | |
| 5 | * they all come back to this one `layout`. | |
| 6 | */ | |
| 7 | ||
| 8 | import { commit, description, env, ssh, title, where } from "../env.ts"; | |
| 9 | import { asset, escape } from "../http.ts"; | |
| 10 | import type { Who } from "../who.ts"; | |
| 11 | ||
| 12 | export function layout(body: string, who: Who, path = "/"): string { | |
| 13 | const to = encodeURIComponent(path); | |
| 14 | const door = | |
| 15 | who.role === "anon" | |
| 16 | ? `<a class="button button-quiet" href="/__forge/signin?to=${escape(to)}">Sign in</a>` | |
| 17 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 18 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 19 | return `<!doctype html> | |
| 20 | <html lang="en"> | |
| 21 | <meta charset="utf-8"> | |
| 22 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | <meta name="color-scheme" content="light dark"> | |
| 24 | <link rel="icon" href="data:,"> | |
| 25 | <title>${escape(title)}</title> | |
| 26 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 27 | <script type="module" src="${asset("live.js")}"></script> | |
| 28 | <body> | |
| 29 | <div class="page"> | |
| 30 | <header class="head"> | |
| 31 | <div> | |
| 32 | <h1><a class="plain" href="/">${escape(title)}</a></h1> | |
| 33 | <p class="muted">${escape(description)}</p> | |
| 34 | </div> | |
| 35 | <nav class="row"> | |
| 36 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 37 | ${door} | |
| 38 | </nav> | |
| 39 | </header> | |
| 40 | <main>${body}</main> | |
| 41 | <footer class="muted"> | |
| 42 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 43 | <p>Also at <a href="/api/proposals">/api/proposals</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 44 | </footer> | |
| 45 | </div> | |
| 46 | </body> | |
| 47 | </html> | |
| 48 | `; | |
| 49 | } | |
| 50 | ||
| 51 | /** A word from the last request, shown once. */ | |
| 52 | export const toast = (notice: string | undefined): string => | |
| 53 | notice === undefined | |
| 54 | ? "" | |
| 55 | : `<p class="toast" role="status">${escape(notice)}</p>`; | |
| 56 | ||
| 57 | /** Something that is not a proposal, in the frame the rest of the app wears. */ | |
| 58 | export const missing = (message: string, who: Who): string => | |
| 59 | layout( | |
| 60 | `<section class="card"><p>${escape(message)}</p><p class="muted"><a href="/">Back to the proposals</a></p></section>`, | |
| 61 | who, | |
| 62 | ); | |
| 63 | ||
| 64 | export const ago = (iso: string): string => { | |
| 65 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 66 | if (s < 60) return "just now"; | |
| 67 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 68 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 69 | return new Date(iso).toLocaleDateString("en", { | |
| 70 | month: "short", | |
| 71 | day: "numeric", | |
| 72 | }); | |
| 73 | }; |
src/pages/proposal.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 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. A guest sees all of it and no forms. | |
| 5 | */ | |
| 6 | ||
| 7 | import { canWithdraw, type FullProposal } from "../actions.ts"; | |
| 8 | import { escape } from "../http.ts"; | |
| 9 | import type { Who } from "../who.ts"; | |
| 10 | import { ago, layout, toast } from "./layout.ts"; | |
| 11 | ||
| 12 | export function proposalPage(input: { | |
| 13 | who: Who; | |
| 14 | proposal: FullProposal; | |
| 15 | notice?: string | undefined; | |
| 16 | }): string { | |
| 17 | const { who, proposal, notice } = input; | |
| 18 | const withdraw = | |
| 19 | proposal.state === "open" && canWithdraw(who, proposal) | |
| 20 | ? `<form method="post" action="/proposals/${proposal.id}/withdraw"> | |
| 21 | <button class="button button-quiet">Withdraw</button> | |
| 22 | </form>` | |
| 23 | : ""; | |
| 24 | return layout( | |
| 25 | `<section id="proposal" data-live="proposal.opened proposal.withdrawn" class="card"> | |
| 26 | <div class="head-row"> | |
| 27 | <h2>${escape(proposal.title)}</h2> | |
| 28 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> | |
| 29 | </div> | |
| 30 | <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time> · decided by ${escape(proposal.rule)}</p> | |
| 31 | <p class="body">${escape(proposal.body)}</p> | |
| 32 | <ol class="options">${proposal.options | |
| 33 | .map( | |
| 34 | (option) => | |
| 35 | `<li${option.id === proposal.decided_option_id ? ` class="chosen"` : ""}>${escape(option.label)}</li>`, | |
| 36 | ) | |
| 37 | .join("")}</ol> | |
| 38 | <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div> | |
| 39 | </section> | |
| 40 | ${toast(notice)}`, | |
| 41 | who, | |
| 42 | `/p/${proposal.id}`, | |
| 43 | ); | |
| 44 | } |
src/routes.ts +87 −24
| @@ -6,9 +6,10 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | addNote, | |
| 10 | deleteNote, | |
| 11 | listNotes, | |
| 9 | getProposal, | |
| 10 | listProposals, | |
| 11 | propose, | |
| 12 | withdrawProposal, | |
| 12 | 13 | type Failure, |
| 13 | 14 | type Result, |
| 14 | 15 | } from "./actions.ts"; |
| @@ -25,7 +26,9 @@ import { | ||
| 25 | 26 | } from "./http.ts"; |
| 26 | 27 | import { events } from "./live.ts"; |
| 27 | 28 | import { rpc } from "./mcp.ts"; |
| 28 | import { home } from "./page.ts"; | |
| 29 | import { home } from "./pages/home.ts"; | |
| 30 | import { missing } from "./pages/layout.ts"; | |
| 31 | import { proposalPage } from "./pages/proposal.ts"; | |
| 29 | 32 | import type { Who } from "./who.ts"; |
| 30 | 33 | |
| 31 | 34 | export interface Context { |
| @@ -37,7 +40,7 @@ export interface Context { | ||
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | export interface Route { |
| 40 | readonly method: "GET" | "POST" | "DELETE"; | |
| 43 | readonly method: "GET" | "POST"; | |
| 41 | 44 | readonly path: string; |
| 42 | 45 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 43 | 46 | } |
| @@ -58,20 +61,56 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 58 | 61 | STATUS[result.error], |
| 59 | 62 | ); |
| 60 | 63 | |
| 61 | /** A result for a form: back to the page, with what went wrong if it did. */ | |
| 62 | const done = <T>(result: Result<T>): Response => | |
| 63 | result.ok ? back() : back(result.message); | |
| 64 | /** A result for a form: back to the page it came from, with what went wrong if it did. */ | |
| 65 | const done = <T>(result: Result<T>, where: string): Response => | |
| 66 | result.ok ? back(undefined, where) : back(result.message, where); | |
| 67 | ||
| 68 | /** A path is only ever built from a whole number, never from what a request carried. */ | |
| 69 | const pathOf = (id: string): string => { | |
| 70 | const n = Number(id); | |
| 71 | return Number.isInteger(n) && n > 0 ? `/p/${n}` : "/"; | |
| 72 | }; | |
| 73 | ||
| 74 | /** A textarea is one option per line; blank lines are the shape of typing, not an option. */ | |
| 75 | const lines = (value: string): string[] => | |
| 76 | value | |
| 77 | .split(/\r?\n/) | |
| 78 | .map((line) => line.trim()) | |
| 79 | .filter((line) => line !== ""); | |
| 80 | ||
| 81 | /** Whatever JSON carried as a list of options, as a list. */ | |
| 82 | const optionsOf = (value: unknown): unknown[] => | |
| 83 | Array.isArray(value) ? value : typeof value === "string" ? lines(value) : []; | |
| 64 | 84 | |
| 65 | 85 | export const routes: readonly Route[] = [ |
| 66 | // The page. Forms post and come back; the list stays live over /events. | |
| 86 | // The pages. Forms post and come back; the lists stay live over /events. | |
| 67 | 87 | { |
| 68 | 88 | method: "GET", |
| 69 | 89 | path: "/", |
| 70 | 90 | handle: (c) => { |
| 71 | 91 | const { notice, clear } = readNotice(c.request); |
| 72 | const notes = listNotes(c.db, c.who); | |
| 92 | const proposals = listProposals(c.db, c.who, { state: "open" }); | |
| 73 | 93 | return html( |
| 74 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 94 | home({ | |
| 95 | who: c.who, | |
| 96 | proposals: proposals.ok ? proposals.value : [], | |
| 97 | notice, | |
| 98 | }), | |
| 99 | 200, | |
| 100 | clear, | |
| 101 | ); | |
| 102 | }, | |
| 103 | }, | |
| 104 | { | |
| 105 | method: "GET", | |
| 106 | path: "/p/:id", | |
| 107 | handle: (c) => { | |
| 108 | const { notice, clear } = readNotice(c.request); | |
| 109 | const found = getProposal(c.db, c.who, { id: Number(c.params.id) }); | |
| 110 | // Anything that is not a proposal — a bad id or a gone one — is a 404. | |
| 111 | if (!found.ok) return html(missing(found.message, c.who), 404, clear); | |
| 112 | return html( | |
| 113 | proposalPage({ who: c.who, proposal: found.value, notice }), | |
| 75 | 114 | 200, |
| 76 | 115 | clear, |
| 77 | 116 | ); |
| @@ -79,48 +118,72 @@ export const routes: readonly Route[] = [ | ||
| 79 | 118 | }, |
| 80 | 119 | { |
| 81 | 120 | method: "POST", |
| 82 | path: "/notes", | |
| 121 | path: "/proposals", | |
| 83 | 122 | handle: async (c) => { |
| 84 | 123 | const form = await readForm(c.request); |
| 85 | 124 | if (form === undefined) return text("too large", 413); |
| 86 | return done( | |
| 87 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 88 | ); | |
| 125 | const made = propose(c.db, c.who, { | |
| 126 | title: String(form.get("title") ?? ""), | |
| 127 | body: String(form.get("body") ?? ""), | |
| 128 | options: lines(String(form.get("options") ?? "")), | |
| 129 | rule: String(form.get("rule") ?? "majority"), | |
| 130 | }); | |
| 131 | return made.ok | |
| 132 | ? back(undefined, `/p/${made.value.id}`) | |
| 133 | : back(made.message, "/"); | |
| 89 | 134 | }, |
| 90 | 135 | }, |
| 91 | 136 | { |
| 92 | 137 | method: "POST", |
| 93 | path: "/notes/:id/delete", | |
| 94 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 138 | path: "/proposals/:id/withdraw", | |
| 139 | handle: (c) => | |
| 140 | done( | |
| 141 | withdrawProposal(c.db, c.who, { id: Number(c.params.id) }), | |
| 142 | pathOf(c.params.id ?? ""), | |
| 143 | ), | |
| 95 | 144 | }, |
| 96 | 145 | |
| 97 | 146 | // The JSON API: the same actions, the same identity, plain status codes. |
| 98 | 147 | { |
| 99 | 148 | method: "GET", |
| 100 | path: "/api/notes", | |
| 149 | path: "/api/proposals", | |
| 101 | 150 | handle: (c) => |
| 102 | 151 | answer( |
| 103 | listNotes(c.db, c.who, { | |
| 152 | listProposals(c.db, c.who, { | |
| 153 | state: c.url.searchParams.get("state") ?? undefined, | |
| 104 | 154 | limit: Number(c.url.searchParams.get("limit") ?? 100), |
| 105 | 155 | }), |
| 106 | 156 | ), |
| 107 | 157 | }, |
| 158 | { | |
| 159 | method: "GET", | |
| 160 | path: "/api/proposals/:id", | |
| 161 | handle: (c) => | |
| 162 | answer(getProposal(c.db, c.who, { id: Number(c.params.id) })), | |
| 163 | }, | |
| 108 | 164 | { |
| 109 | 165 | method: "POST", |
| 110 | path: "/api/notes", | |
| 166 | path: "/api/proposals", | |
| 111 | 167 | handle: async (c) => { |
| 112 | 168 | const body = await readJson(c.request); |
| 113 | 169 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 170 | const input = fields(body); | |
| 114 | 171 | return answer( |
| 115 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), | |
| 172 | propose(c.db, c.who, { | |
| 173 | title: String(input.title ?? ""), | |
| 174 | body: String(input.body ?? ""), | |
| 175 | options: optionsOf(input.options), | |
| 176 | rule: String(input.rule ?? "majority"), | |
| 177 | }), | |
| 116 | 178 | 201, |
| 117 | 179 | ); |
| 118 | 180 | }, |
| 119 | 181 | }, |
| 120 | 182 | { |
| 121 | method: "DELETE", | |
| 122 | path: "/api/notes/:id", | |
| 123 | handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 183 | method: "POST", | |
| 184 | path: "/api/proposals/:id/withdraw", | |
| 185 | handle: (c) => | |
| 186 | answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })), | |
| 124 | 187 | }, |
| 125 | 188 | |
| 126 | 189 | // Live events, and the MCP server (the tools are the actions too). |
src/server.test.ts +187 −59
| @@ -46,79 +46,183 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 46 | 46 | api("/mcp", { method: "POST", ...jsonBody(message) }, who).then( |
| 47 | 47 | (r) => r.json() as Promise<any>, |
| 48 | 48 | ); |
| 49 | const proposal = { | |
| 50 | title: "Where do we meet", | |
| 51 | body: "One room for the retrospective.", | |
| 52 | options: ["The kitchen", "The garden"], | |
| 53 | }; | |
| 54 | /** A proposal over the API, for the tests that need one to look at. */ | |
| 55 | const open = async (who: Record<string, string>, over: unknown = {}) => { | |
| 56 | const created = await api( | |
| 57 | "/api/proposals", | |
| 58 | { method: "POST", ...jsonBody({ ...proposal, ...(over as object) }) }, | |
| 59 | who, | |
| 60 | ); | |
| 61 | expect(created.status).toBe(201); | |
| 62 | return (await created.json()) as { id: number; author_name: string }; | |
| 63 | }; | |
| 49 | 64 | |
| 50 | 65 | test("the page knows who you are and offers the right door", async () => { |
| 51 | 66 | const guest = await (await fetch(base + "/")).text(); |
| 52 | 67 | expect(guest).toContain("<h1>"); |
| 53 | expect(guest).toContain("/__forge/signin?to=/"); | |
| 54 | expect(guest).not.toContain('action="/notes"'); | |
| 68 | expect(guest).toContain("/__forge/signin?to="); | |
| 69 | expect(guest).not.toContain('action="/proposals"'); | |
| 55 | 70 | const signed = await (await api("/", {}, alice)).text(); |
| 56 | 71 | expect(signed).toContain("<b>alice</b>"); |
| 57 | 72 | expect(signed).toContain("/__forge/signout"); |
| 58 | expect(signed).toContain('action="/notes"'); | |
| 73 | expect(signed).toContain('action="/proposals"'); | |
| 59 | 74 | }); |
| 60 | 75 | |
| 61 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 76 | test("a form post proposes as the person and lands on the proposal", async () => { | |
| 62 | 77 | const form = new FormData(); |
| 63 | form.set("body", "hello from a form"); | |
| 64 | const refused = await api("/notes", { | |
| 78 | form.set("title", "Paint the shed"); | |
| 79 | form.set("body", "It has been grey for years."); | |
| 80 | form.set("options", "Blue\nGreen\n\n"); | |
| 81 | const refused = await api("/proposals", { | |
| 65 | 82 | method: "POST", |
| 66 | 83 | body: form, |
| 67 | 84 | redirect: "manual", |
| 68 | 85 | }); |
| 69 | 86 | expect(refused.status).toBe(303); |
| 87 | expect(refused.headers.get("location")).toBe("/"); | |
| 70 | 88 | expect(refused.headers.get("set-cookie")).toContain("notice="); |
| 71 | 89 | expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax"); |
| 72 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 73 | "hello from a form", | |
| 74 | ); | |
| 90 | expect(await (await fetch(base + "/")).text()).not.toContain("Paint the shed"); | |
| 91 | ||
| 75 | 92 | const posted = await api( |
| 76 | "/notes", | |
| 93 | "/proposals", | |
| 77 | 94 | { method: "POST", body: form, redirect: "manual" }, |
| 78 | 95 | alice, |
| 79 | 96 | ); |
| 80 | 97 | expect(posted.status).toBe(303); |
| 81 | 98 | expect(posted.headers.get("set-cookie")).toBeNull(); |
| 99 | const to = posted.headers.get("location") ?? ""; | |
| 100 | expect(to).toMatch(/^\/p\/\d+$/); | |
| 101 | ||
| 82 | 102 | const page = await (await api("/", {}, alice)).text(); |
| 83 | expect(page).toContain("hello from a form"); | |
| 103 | expect(page).toContain("Paint the shed"); | |
| 84 | 104 | expect(page).toContain("alice ·"); |
| 105 | expect(page).toContain("2 options"); | |
| 106 | ||
| 107 | const one = await (await api(to, {}, alice)).text(); | |
| 108 | expect(one).toContain("It has been grey for years."); | |
| 109 | expect(one).toContain("<li>Blue</li>"); | |
| 110 | expect(one).toContain("<li>Green</li>"); | |
| 111 | expect(one).toContain(`action="/proposals${to.slice(2)}/withdraw"`); | |
| 112 | ||
| 113 | // A blank options line is the shape of typing, not a ninth option. | |
| 114 | const short = new FormData(); | |
| 115 | short.set("title", "Too few"); | |
| 116 | short.set("body", "Only one way to go."); | |
| 117 | short.set("options", "Only this\n"); | |
| 118 | const invalid = await api( | |
| 119 | "/proposals", | |
| 120 | { method: "POST", body: short, redirect: "manual" }, | |
| 121 | alice, | |
| 122 | ); | |
| 123 | expect(invalid.headers.get("location")).toBe("/"); | |
| 124 | expect( | |
| 125 | decodeURIComponent(invalid.headers.get("set-cookie") ?? ""), | |
| 126 | ).toContain("A proposal has 2 to 8 options"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a proposal page shows the options and hides the form from a guest", async () => { | |
| 130 | const made = await open(alice, { title: "Escaping <script>" }); | |
| 131 | const guest = await (await fetch(`${base}/p/${made.id}`)).text(); | |
| 132 | expect(guest).toContain("Escaping <script>"); | |
| 133 | expect(guest).not.toContain("<script>Escaping"); | |
| 134 | expect(guest).not.toContain("/withdraw"); | |
| 135 | expect(guest).toContain("The kitchen"); | |
| 136 | const other = await (await api(`/p/${made.id}`, {}, bob)).text(); | |
| 137 | expect(other).not.toContain("/withdraw"); | |
| 138 | const mine = await (await api(`/p/${made.id}`, {}, alice)).text(); | |
| 139 | expect(mine).toContain(`action="/proposals/${made.id}/withdraw"`); | |
| 140 | }); | |
| 141 | ||
| 142 | test("something that is not a proposal is a 404 in the layout", async () => { | |
| 143 | for (const path of ["/p/9999", "/p/not-a-number"]) { | |
| 144 | const response = await fetch(base + path); | |
| 145 | expect(response.status).toBe(404); | |
| 146 | expect(response.headers.get("content-type")).toContain("text/html"); | |
| 147 | const page = await response.text(); | |
| 148 | expect(page).toContain("<h1>"); | |
| 149 | expect(page).toContain("Back to the proposals"); | |
| 150 | } | |
| 151 | expect((await fetch(base + "/api/proposals/9999")).status).toBe(404); | |
| 152 | }); | |
| 153 | ||
| 154 | test("withdrawing over a form comes back to the proposal", async () => { | |
| 155 | const made = await open(alice); | |
| 156 | const withdrawn = await api( | |
| 157 | `/proposals/${made.id}/withdraw`, | |
| 158 | { method: "POST", redirect: "manual" }, | |
| 159 | bob, | |
| 160 | ); | |
| 161 | expect(withdrawn.status).toBe(303); | |
| 162 | expect(withdrawn.headers.get("location")).toBe(`/p/${made.id}`); | |
| 163 | expect( | |
| 164 | decodeURIComponent(withdrawn.headers.get("set-cookie") ?? ""), | |
| 165 | ).toContain("author or an admin"); | |
| 166 | const done = await api( | |
| 167 | `/proposals/${made.id}/withdraw`, | |
| 168 | { method: "POST", redirect: "manual" }, | |
| 169 | alice, | |
| 170 | ); | |
| 171 | expect(done.headers.get("set-cookie")).toBeNull(); | |
| 172 | const page = await (await api(`/p/${made.id}`, {}, alice)).text(); | |
| 173 | expect(page).toContain("withdrawn"); | |
| 174 | expect(page).not.toContain("/withdraw"); | |
| 175 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 176 | `/p/${made.id}"`, | |
| 177 | ); | |
| 85 | 178 | }); |
| 86 | 179 | |
| 87 | 180 | test("the JSON API is the same actions with the same identity", async () => { |
| 88 | const created = await api( | |
| 89 | "/api/notes", | |
| 90 | { method: "POST", ...jsonBody({ body: "over the api" }) }, | |
| 91 | alice, | |
| 92 | ); | |
| 93 | expect(created.status).toBe(201); | |
| 94 | const note = (await created.json()) as { id: number; author_name: string }; | |
| 95 | expect(note.author_name).toBe("alice"); | |
| 96 | const listed = (await (await fetch(base + "/api/notes")).json()) as { | |
| 181 | const made = await open(alice); | |
| 182 | expect(made.author_name).toBe("alice"); | |
| 183 | const listed = (await (await fetch(base + "/api/proposals")).json()) as { | |
| 97 | 184 | id: number; |
| 185 | option_count: number; | |
| 98 | 186 | }[]; |
| 99 | expect(listed.map((n) => n.id)).toContain(note.id); | |
| 187 | expect(listed.map((p) => p.id)).toContain(made.id); | |
| 188 | expect(listed[0]?.option_count).toBe(2); | |
| 189 | const one = (await ( | |
| 190 | await fetch(`${base}/api/proposals/${made.id}`) | |
| 191 | ).json()) as { options: { label: string }[] }; | |
| 192 | expect(one.options.map((o) => o.label)).toEqual([ | |
| 193 | "The kitchen", | |
| 194 | "The garden", | |
| 195 | ]); | |
| 100 | 196 | expect( |
| 101 | (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) })) | |
| 197 | (await api("/api/proposals", { method: "POST", ...jsonBody(proposal) })) | |
| 102 | 198 | .status, |
| 103 | 199 | ).toBe(401); |
| 104 | expect( | |
| 105 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 106 | ).toBe(403); | |
| 107 | expect( | |
| 108 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 109 | ).toBe(200); | |
| 110 | expect( | |
| 111 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 112 | ).toBe(404); | |
| 113 | 200 | expect( |
| 114 | 201 | ( |
| 115 | 202 | await api( |
| 116 | "/api/notes", | |
| 117 | { method: "POST", ...jsonBody({ body: "" }) }, | |
| 203 | "/api/proposals", | |
| 204 | { method: "POST", ...jsonBody({ ...proposal, options: ["One"] }) }, | |
| 118 | 205 | alice, |
| 119 | 206 | ) |
| 120 | 207 | ).status, |
| 121 | 208 | ).toBe(422); |
| 209 | expect((await fetch(base + "/api/proposals?state=argued")).status).toBe(422); | |
| 210 | expect( | |
| 211 | (await api(`/api/proposals/${made.id}/withdraw`, { method: "POST" }, bob)) | |
| 212 | .status, | |
| 213 | ).toBe(403); | |
| 214 | expect( | |
| 215 | (await api(`/api/proposals/${made.id}/withdraw`, { method: "POST" }, root)) | |
| 216 | .status, | |
| 217 | ).toBe(200); | |
| 218 | expect( | |
| 219 | (await api(`/api/proposals/${made.id}/withdraw`, { method: "POST" }, root)) | |
| 220 | .status, | |
| 221 | ).toBe(422); | |
| 222 | expect( | |
| 223 | (await api("/api/proposals/9999/withdraw", { method: "POST" }, root)) | |
| 224 | .status, | |
| 225 | ).toBe(404); | |
| 122 | 226 | }); |
| 123 | 227 | |
| 124 | 228 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| @@ -131,9 +235,10 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 131 | 235 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 132 | 236 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 133 | 237 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 134 | "list_notes", | |
| 135 | "add_note", | |
| 136 | "delete_note", | |
| 238 | "list_proposals", | |
| 239 | "get_proposal", | |
| 240 | "propose", | |
| 241 | "withdraw_proposal", | |
| 137 | 242 | ]); |
| 138 | 243 | const call = (name: string, args: unknown, who = {}) => |
| 139 | 244 | rpc( |
| @@ -145,18 +250,22 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 145 | 250 | }, |
| 146 | 251 | who, |
| 147 | 252 | ); |
| 148 | const refused = await call("add_note", { body: "as nobody" }); | |
| 253 | const refused = await call("propose", proposal); | |
| 149 | 254 | expect(refused.result.isError).toBe(true); |
| 150 | const added = await call("add_note", { body: "over mcp" }, root); | |
| 151 | expect(added.result.isError).toBeUndefined(); | |
| 152 | const note = JSON.parse(added.result.content[0].text); | |
| 153 | expect(note.author_name).toBe("root"); | |
| 154 | const notes = JSON.parse( | |
| 155 | (await call("list_notes", {})).result.content[0].text, | |
| 255 | const made = await call("propose", proposal, root); | |
| 256 | expect(made.result.isError).toBeUndefined(); | |
| 257 | const one = JSON.parse(made.result.content[0].text); | |
| 258 | expect(one.author_name).toBe("root"); | |
| 259 | const all = JSON.parse( | |
| 260 | (await call("list_proposals", { state: "open" })).result.content[0].text, | |
| 156 | 261 | ); |
| 157 | expect(notes.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 262 | expect(all.map((p: { id: number }) => p.id)).toContain(one.id); | |
| 158 | 263 | expect( |
| 159 | (await call("delete_note", { id: note.id }, root)).result.isError, | |
| 264 | JSON.parse((await call("get_proposal", { id: one.id })).result.content[0].text) | |
| 265 | .options.length, | |
| 266 | ).toBe(2); | |
| 267 | expect( | |
| 268 | (await call("withdraw_proposal", { id: one.id }, root)).result.isError, | |
| 160 | 269 | ).toBeUndefined(); |
| 161 | 270 | expect( |
| 162 | 271 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| @@ -181,13 +290,9 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 181 | 290 | if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type }; |
| 182 | 291 | } |
| 183 | 292 | }; |
| 184 | await api( | |
| 185 | "/api/notes", | |
| 186 | { method: "POST", ...jsonBody({ body: "live" }) }, | |
| 187 | alice, | |
| 188 | ); | |
| 293 | const made = await open(alice, { title: "Live" }); | |
| 189 | 294 | const heard = await next(); |
| 190 | expect(heard.type).toBe("note.added"); | |
| 295 | expect(heard.type).toBe("proposal.opened"); | |
| 191 | 296 | await reader.cancel(); |
| 192 | 297 | const late = await fetch(base + "/events", { |
| 193 | 298 | headers: { "last-event-id": String(Number(heard.id) - 1) }, |
| @@ -196,16 +301,32 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 196 | 301 | (await late.body!.getReader().read()).value, |
| 197 | 302 | ); |
| 198 | 303 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 199 | expect(replay).toContain("note.added"); | |
| 304 | expect(replay).toContain("proposal.opened"); | |
| 305 | ||
| 306 | const second = await fetch(base + "/events"); | |
| 307 | const watching = second.body!.getReader(); | |
| 308 | await api(`/api/proposals/${made.id}/withdraw`, { method: "POST" }, alice); | |
| 309 | let buffer = ""; | |
| 310 | for (;;) { | |
| 311 | const { value } = await watching.read(); | |
| 312 | buffer += new TextDecoder().decode(value); | |
| 313 | if (buffer.includes("proposal.withdrawn")) break; | |
| 314 | } | |
| 315 | await watching.cancel(); | |
| 316 | const page = await (await fetch(base + "/")).text(); | |
| 317 | expect(page).toContain('data-live="proposal.opened proposal.withdrawn"'); | |
| 200 | 318 | }); |
| 201 | 319 | |
| 202 | 320 | test("writes are rationed per person", async () => { |
| 203 | 321 | const flood = as("u9", "flood"); |
| 204 | 322 | const answers = await Promise.all( |
| 205 | Array.from({ length: 40 }, () => | |
| 323 | Array.from({ length: 40 }, (_, i) => | |
| 206 | 324 | api( |
| 207 | "/api/notes", | |
| 208 | { method: "POST", ...jsonBody({ body: "again" }) }, | |
| 325 | "/api/proposals", | |
| 326 | { | |
| 327 | method: "POST", | |
| 328 | ...jsonBody({ ...proposal, title: `Again ${i}` }), | |
| 329 | }, | |
| 209 | 330 | flood, |
| 210 | 331 | ), |
| 211 | 332 | ), |
| @@ -222,13 +343,15 @@ test("stands on the security floor", async () => { | ||
| 222 | 343 | ); |
| 223 | 344 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 224 | 345 | expect( |
| 225 | (await fetch(base + "/api/notes")).headers.get("content-security-policy"), | |
| 346 | (await fetch(base + "/api/proposals")).headers.get( | |
| 347 | "content-security-policy", | |
| 348 | ), | |
| 226 | 349 | ).toContain("default-src"); |
| 227 | 350 | }); |
| 228 | 351 | |
| 229 | 352 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 230 | 353 | const response = await api( |
| 231 | "/notes", | |
| 354 | "/proposals", | |
| 232 | 355 | { |
| 233 | 356 | method: "POST", |
| 234 | 357 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| @@ -247,9 +370,11 @@ test("HEAD sees the app up", async () => { | ||
| 247 | 370 | |
| 248 | 371 | test("refuses a cross-site post", async () => { |
| 249 | 372 | const form = new FormData(); |
| 250 | form.set("body", "from another site"); | |
| 373 | form.set("title", "From another site"); | |
| 374 | form.set("body", "Should never land."); | |
| 375 | form.set("options", "Yes\nNo"); | |
| 251 | 376 | const posted = await api( |
| 252 | "/notes", | |
| 377 | "/proposals", | |
| 253 | 378 | { |
| 254 | 379 | method: "POST", |
| 255 | 380 | body: form, |
| @@ -262,6 +387,9 @@ test("refuses a cross-site post", async () => { | ||
| 262 | 387 | alice, |
| 263 | 388 | ); |
| 264 | 389 | expect(posted.status).toBe(403); |
| 390 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 391 | "From another site", | |
| 392 | ); | |
| 265 | 393 | }); |
| 266 | 394 | |
| 267 | 395 | test("static files are cached for a year when linked by content", async () => { |
src/tty.ts +185 −55
| @@ -1,16 +1,20 @@ | ||
| 1 | 1 | /** |
| 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 | * actions, as the account at the keyboard. j/k move, a adds, d removes the | |
| 5 | * note under the cursor, q leaves. Plain ANSI, no dependencies. | |
| 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. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { |
| 9 | addNote, | |
| 10 | canDelete, | |
| 11 | deleteNote, | |
| 12 | listNotes, | |
| 13 | type Note, | |
| 10 | canWithdraw, | |
| 11 | getProposal, | |
| 12 | listProposals, | |
| 13 | propose, | |
| 14 | withdrawProposal, | |
| 15 | OPTIONS_MAX, | |
| 16 | type FullProposal, | |
| 17 | type ProposalSummary, | |
| 14 | 18 | } from "./actions.ts"; |
| 15 | 19 | import { openDb } from "./db.ts"; |
| 16 | 20 | import { ssh, title, where } from "./env.ts"; |
| @@ -25,48 +29,158 @@ const out = (s: string): void => { | ||
| 25 | 29 | process.stdout.write(s); |
| 26 | 30 | }; |
| 27 | 31 | |
| 32 | /** What is being typed, and how much of a proposal has been said so far. */ | |
| 33 | interface Draft { | |
| 34 | step: "title" | "body" | "options"; | |
| 35 | title: string; | |
| 36 | body: string; | |
| 37 | options: string[]; | |
| 38 | typing: string; | |
| 39 | } | |
| 40 | ||
| 28 | 41 | let cursor = 0; |
| 29 | let typing: string | undefined; | |
| 42 | let open: number | undefined; | |
| 43 | let draft: Draft | undefined; | |
| 30 | 44 | let said = ""; |
| 31 | 45 | |
| 32 | const notes = (): Note[] => { | |
| 33 | const listed = listNotes(db, me); | |
| 46 | const proposals = (): ProposalSummary[] => { | |
| 47 | const listed = listProposals(db, me, { state: "open" }); | |
| 34 | 48 | return listed.ok ? listed.value : []; |
| 35 | 49 | }; |
| 36 | 50 | |
| 51 | const one = (id: number): FullProposal | undefined => { | |
| 52 | const found = getProposal(db, me, { id }); | |
| 53 | return found.ok ? found.value : undefined; | |
| 54 | }; | |
| 55 | ||
| 56 | /** Long text, broken at spaces so a narrow terminal still reads. */ | |
| 57 | const wrap = (text: string, width = 72): string[] => | |
| 58 | text.split(/\r?\n/).flatMap((paragraph) => { | |
| 59 | const lines: string[] = []; | |
| 60 | let line = ""; | |
| 61 | for (const word of paragraph.split(/\s+/)) { | |
| 62 | if (word === "") continue; | |
| 63 | if (line === "") line = word; | |
| 64 | else if (line.length + 1 + word.length <= width) line += ` ${word}`; | |
| 65 | else { | |
| 66 | lines.push(line); | |
| 67 | line = word; | |
| 68 | } | |
| 69 | } | |
| 70 | lines.push(line); | |
| 71 | return lines; | |
| 72 | }); | |
| 73 | ||
| 37 | 74 | function draw(): void { |
| 38 | const rows = notes(); | |
| 39 | 75 | const lines = [ |
| 40 | 76 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, |
| 41 | 77 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| 42 | 78 | "", |
| 43 | 79 | ]; |
| 44 | if (rows.length === 0) | |
| 45 | lines.push( | |
| 46 | dim( | |
| 47 | me.role === "anon" | |
| 48 | ? "No notes yet." | |
| 49 | : "No notes yet. Press a to add one.", | |
| 50 | ), | |
| 51 | ); | |
| 52 | rows.forEach((note, i) => { | |
| 53 | const body = i === cursor ? bold(note.body) : note.body; | |
| 54 | lines.push( | |
| 55 | `${i === cursor ? "▸" : " "} ${body} ${dim(`${note.author_name} · ${note.created_at.slice(0, 16).replace("T", " ")}`)}`, | |
| 56 | ); | |
| 57 | }); | |
| 80 | if (draft !== undefined) lines.push(...composer(draft)); | |
| 81 | else if (open !== undefined) lines.push(...detail(open)); | |
| 82 | else lines.push(...list()); | |
| 58 | 83 | lines.push(""); |
| 59 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 60 | else if (said !== "") lines.push(said); | |
| 61 | else | |
| 62 | lines.push( | |
| 84 | lines.push(said !== "" ? said : dim(keys())); | |
| 85 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 86 | } | |
| 87 | ||
| 88 | const list = (): string[] => { | |
| 89 | const rows = proposals(); | |
| 90 | if (rows.length === 0) | |
| 91 | return [ | |
| 63 | 92 | dim( |
| 64 | 93 | me.role === "anon" |
| 65 | ? "j/k move · q quit · sign in with a key the forge knows to add" | |
| 66 | : "j/k move · a add · d delete · q quit", | |
| 94 | ? "No open proposals." | |
| 95 | : "No open proposals. Press n to make one.", | |
| 67 | 96 | ), |
| 97 | ]; | |
| 98 | return rows.map((p, i) => { | |
| 99 | const line = `${p.title} ${dim(`${p.author_name} · ${p.option_count} options · ${p.created_at.slice(0, 16).replace("T", " ")}`)}`; | |
| 100 | return `${i === cursor ? "▸" : " "} ${i === cursor ? bold(p.title) + line.slice(p.title.length) : line}`; | |
| 101 | }); | |
| 102 | }; | |
| 103 | ||
| 104 | const detail = (id: number): string[] => { | |
| 105 | const proposal = one(id); | |
| 106 | if (proposal === undefined) return [dim("That proposal is gone.")]; | |
| 107 | return [ | |
| 108 | bold(proposal.title), | |
| 109 | dim( | |
| 110 | `${proposal.author_name} · ${proposal.state} · decided by ${proposal.rule} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`, | |
| 111 | ), | |
| 112 | "", | |
| 113 | ...wrap(proposal.body), | |
| 114 | "", | |
| 115 | ...proposal.options.map((o) => ` ${o.position + 1}. ${o.label}`), | |
| 116 | ]; | |
| 117 | }; | |
| 118 | ||
| 119 | const composer = (d: Draft): string[] => { | |
| 120 | const cell = `${esc}7m ${esc}0m`; | |
| 121 | const lines = [bold("A new proposal"), ""]; | |
| 122 | lines.push( | |
| 123 | d.step === "title" ? `Title: ${d.typing}${cell}` : dim(`Title: ${d.title}`), | |
| 124 | ); | |
| 125 | if (d.step !== "title") | |
| 126 | lines.push( | |
| 127 | d.step === "body" | |
| 128 | ? `Paragraph: ${d.typing}${cell}` | |
| 129 | : dim(`Paragraph: ${d.body}`), | |
| 68 | 130 | ); |
| 69 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 131 | if (d.step === "options") { | |
| 132 | lines.push(dim(`Options (${d.options.length}), blank line when done:`)); | |
| 133 | d.options.forEach((label, i) => lines.push(dim(` ${i + 1}. ${label}`))); | |
| 134 | lines.push(` ${d.options.length + 1}. ${d.typing}${cell}`); | |
| 135 | } | |
| 136 | return lines; | |
| 137 | }; | |
| 138 | ||
| 139 | const keys = (): string => { | |
| 140 | if (draft !== undefined) return "Enter accepts · Esc cancels"; | |
| 141 | if (open !== undefined) return "Esc back · w withdraw · q quit"; | |
| 142 | return me.role === "anon" | |
| 143 | ? "j/k move · Enter open · q quit · sign in through the forge to propose" | |
| 144 | : "j/k move · Enter open · n propose · w withdraw · q quit"; | |
| 145 | }; | |
| 146 | ||
| 147 | /** The draft, finished: the same action every other door calls. */ | |
| 148 | function submit(d: Draft): void { | |
| 149 | const made = propose(db, me, { | |
| 150 | title: d.title, | |
| 151 | body: d.body, | |
| 152 | options: d.options, | |
| 153 | }); | |
| 154 | if (made.ok) { | |
| 155 | said = `Proposed: ${made.value.title}`; | |
| 156 | draft = undefined; | |
| 157 | open = made.value.id; | |
| 158 | cursor = 0; | |
| 159 | } else { | |
| 160 | // Keep what was typed: the message says what to change. | |
| 161 | said = made.message; | |
| 162 | d.step = "options"; | |
| 163 | d.typing = ""; | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | function withdraw(id: number | undefined): void { | |
| 168 | if (id === undefined) { | |
| 169 | said = "Nothing to withdraw."; | |
| 170 | return; | |
| 171 | } | |
| 172 | const proposal = one(id); | |
| 173 | if (proposal === undefined) said = "No such proposal."; | |
| 174 | else if (!canWithdraw(me, proposal)) | |
| 175 | said = | |
| 176 | me.role === "anon" | |
| 177 | ? "Sign in to withdraw a proposal." | |
| 178 | : "Only the author or an admin can withdraw a proposal."; | |
| 179 | else { | |
| 180 | const done = withdrawProposal(db, me, { id }); | |
| 181 | said = done.ok ? "Withdrawn." : done.message; | |
| 182 | if (done.ok) cursor = 0; | |
| 183 | } | |
| 70 | 184 | } |
| 71 | 185 | |
| 72 | 186 | process.stdin.setRawMode?.(true); |
| @@ -75,15 +189,31 @@ process.stdin.setEncoding("utf8"); | ||
| 75 | 189 | draw(); |
| 76 | 190 | process.stdin.on("data", (key: string) => { |
| 77 | 191 | said = ""; |
| 78 | if (typing !== undefined) { | |
| 79 | if (key === "\r") { | |
| 80 | const added = addNote(db, me, { body: typing }); | |
| 81 | if (!added.ok) said = added.message; | |
| 82 | typing = undefined; | |
| 83 | cursor = 0; | |
| 84 | } else if (key === "\x1b") typing = undefined; | |
| 85 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 86 | else if (key >= " ") typing += key; | |
| 192 | if (draft !== undefined) { | |
| 193 | const d = draft; | |
| 194 | if (key === "\x1b") { | |
| 195 | draft = undefined; | |
| 196 | said = "Cancelled."; | |
| 197 | } else if (key === "\r") { | |
| 198 | if (d.step === "title") { | |
| 199 | d.title = d.typing.trim(); | |
| 200 | d.step = "body"; | |
| 201 | d.typing = ""; | |
| 202 | } else if (d.step === "body") { | |
| 203 | d.body = d.typing.trim(); | |
| 204 | d.step = "options"; | |
| 205 | d.typing = ""; | |
| 206 | } else if (d.typing.trim() === "") submit(d); | |
| 207 | else if (d.options.length >= OPTIONS_MAX) | |
| 208 | said = `A proposal has at most ${OPTIONS_MAX} options; press Enter on a blank line.`; | |
| 209 | else { | |
| 210 | d.options.push(d.typing.trim()); | |
| 211 | d.typing = ""; | |
| 212 | } | |
| 213 | } else if (key === "\x03") { | |
| 214 | draft = undefined; | |
| 215 | } else if (key === "\x7f") d.typing = d.typing.slice(0, -1); | |
| 216 | else if (key >= " ") d.typing += key; | |
| 87 | 217 | draw(); |
| 88 | 218 | return; |
| 89 | 219 | } |
| @@ -91,25 +221,25 @@ process.stdin.on("data", (key: string) => { | ||
| 91 | 221 | out(`${esc}2J${esc}H`); |
| 92 | 222 | process.exit(0); |
| 93 | 223 | } |
| 94 | const rows = notes(); | |
| 224 | const rows = proposals(); | |
| 225 | const under = open ?? rows[cursor]?.id; | |
| 95 | 226 | if (key === "j" || key === "\x1b[B") |
| 96 | 227 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); |
| 97 | 228 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); |
| 98 | if (key === "a") { | |
| 99 | if (me.role === "anon") said = "Sign in to add a note."; | |
| 100 | else typing = ""; | |
| 229 | if (key === "\x1b") open = undefined; | |
| 230 | if (key === "\r") { | |
| 231 | const row = rows[cursor]; | |
| 232 | if (open !== undefined) open = undefined; | |
| 233 | else if (row === undefined) said = "Nothing to open."; | |
| 234 | else open = row.id; | |
| 101 | 235 | } |
| 102 | if (key === "d") { | |
| 103 | const note = rows[cursor]; | |
| 104 | if (note === undefined) said = "Nothing to remove."; | |
| 105 | else if (!canDelete(me, note)) | |
| 106 | said = "Only the author or an admin can remove a note."; | |
| 107 | else { | |
| 108 | const removed = deleteNote(db, me, { id: note.id }); | |
| 109 | if (!removed.ok) said = removed.message; | |
| 110 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); | |
| 111 | } | |
| 236 | if (key === "n") { | |
| 237 | if (me.role === "anon") | |
| 238 | said = "Sign in through the forge to make a proposal."; | |
| 239 | else | |
| 240 | draft = { step: "title", title: "", body: "", options: [], typing: "" }; | |
| 112 | 241 | } |
| 242 | if (key === "w") withdraw(under); | |
| 113 | 243 | draw(); |
| 114 | 244 | }); |
| 115 | 245 | process.stdout.on("resize", draw); |
static/app.css +77 −4
| @@ -127,7 +127,26 @@ code, | ||
| 127 | 127 | margin: 0; |
| 128 | 128 | padding: 0; |
| 129 | 129 | } |
| 130 | .note { | |
| 130 | .stack { | |
| 131 | display: grid; | |
| 132 | gap: var(); | |
| 133 | } | |
| 134 | .field { | |
| 135 | display: grid; | |
| 136 | gap: var(); | |
| 137 | } | |
| 138 | .field > span { | |
| 139 | font-size: var(); | |
| 140 | font-weight: 600; | |
| 141 | } | |
| 142 | .small { | |
| 143 | font-size: var(); | |
| 144 | } | |
| 145 | .plain { | |
| 146 | color: inherit; | |
| 147 | text-decoration: none; | |
| 148 | } | |
| 149 | .proposal { | |
| 131 | 150 | display: flex; |
| 132 | 151 | justify-content: space-between; |
| 133 | 152 | align-items: flex-start; |
| @@ -135,15 +154,55 @@ code, | ||
| 135 | 154 | padding: var() 0; |
| 136 | 155 | border-top: 1px solid var(); |
| 137 | 156 | } |
| 138 | .note:first-child { | |
| 157 | .proposal:first-child { | |
| 139 | 158 | border-top: 0; |
| 140 | 159 | } |
| 141 | .note p { | |
| 160 | .proposal p { | |
| 142 | 161 | overflow-wrap: anywhere; |
| 143 | 162 | } |
| 144 | .note small { | |
| 163 | .proposal small { | |
| 145 | 164 | font-size: var(); |
| 146 | 165 | } |
| 166 | .title { | |
| 167 | font-size: var(); | |
| 168 | font-weight: 600; | |
| 169 | color: inherit; | |
| 170 | text-decoration: none; | |
| 171 | line-height: 1.3; | |
| 172 | } | |
| 173 | .title:hover { | |
| 174 | text-decoration: underline; | |
| 175 | } | |
| 176 | .head-row { | |
| 177 | display: flex; | |
| 178 | justify-content: space-between; | |
| 179 | align-items: baseline; | |
| 180 | gap: var(); | |
| 181 | } | |
| 182 | .head-row h2 { | |
| 183 | margin: 0; | |
| 184 | font-size: var(); | |
| 185 | line-height: 1.3; | |
| 186 | overflow-wrap: anywhere; | |
| 187 | } | |
| 188 | .body { | |
| 189 | margin: var() 0; | |
| 190 | white-space: pre-wrap; | |
| 191 | overflow-wrap: anywhere; | |
| 192 | } | |
| 193 | .options { | |
| 194 | margin: 0 0 var(); | |
| 195 | padding-left: 1.4rem; | |
| 196 | display: grid; | |
| 197 | gap: var(); | |
| 198 | } | |
| 199 | .options li { | |
| 200 | overflow-wrap: anywhere; | |
| 201 | } | |
| 202 | .options .chosen { | |
| 203 | font-weight: 650; | |
| 204 | color: var(); | |
| 205 | } | |
| 147 | 206 | .empty { |
| 148 | 207 | padding: var() var(); |
| 149 | 208 | text-align: center; |
| @@ -173,6 +232,12 @@ footer code { | ||
| 173 | 232 | border: 1px solid var(); |
| 174 | 233 | border-radius: var(); |
| 175 | 234 | } |
| 235 | textarea.input { | |
| 236 | min-height: calc(2 * var()); | |
| 237 | padding: var() var(); | |
| 238 | line-height: 1.5; | |
| 239 | resize: vertical; | |
| 240 | } | |
| 176 | 241 | .input::placeholder { |
| 177 | 242 | color: var(); |
| 178 | 243 | } |
| @@ -217,6 +282,14 @@ footer code { | ||
| 217 | 282 | color: var(); |
| 218 | 283 | border-color: currentColor; |
| 219 | 284 | } |
| 285 | .pill[data-state="open"] { | |
| 286 | color: var(); | |
| 287 | border-color: currentColor; | |
| 288 | } | |
| 289 | .pill[data-state="withdrawn"] { | |
| 290 | color: var(); | |
| 291 | border-color: currentColor; | |
| 292 | } | |
| 220 | 293 | |
| 221 | 294 | /* A word from the last request, shown once, gone in a moment. */ |
| 222 | 295 | .toast { |
p