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

Propose, read and withdraw: a proposal with options replaces the note #12

Merged opened this

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

Merged

ledger/quorum:ledger/2-propose-read-and-withdraw-a-proposal-wit → main 2 commits fork 1 thread · 1 unresolved

  • Merged. ledger/2-propose-read-and-withdraw-a-proposal-wit was merged into main as eb85c5e.

Review

Sign in to review this change.
15 files changed +1210 −342
README.md +31 −13
@@ -23,13 +23,13 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2323
2424 | file | what it is |
2525 | ---------------------------------- | ------------------------------------------------------------ |
26 | `src/actions.ts` | list, add, delete — and who may |
26 | `src/actions.ts` | propose, list, read, withdraw — and who may |
2727 | `src/who.ts` | who is asking, from the forge's signed headers |
2828 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
2929 | `src/live.ts` | the in-process bus and `GET /events` |
3030 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
3131 | `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 |
3333 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
3434 | `src/server.ts` | boot: open the database, serve |
3535 | `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
4242 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4343
4444 - **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.
4848
4949 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5050 `/__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:
5454 The same actions, the same rules, at every door. A script is the person
5555 whose forge grant it carries; a bare script is anonymous.
5656
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
5763 ```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
6170 ```
6271
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`:
6579
6680 ```sh
6781 claude mcp add --transport http __TITLE__ https://<app>/mcp
@@ -73,14 +87,18 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp
7387 }
7488 ```
7589
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
7892 reload behind.
7993
8094 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
8195 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
8296 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.
84102
85103 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
86104 `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";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 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";
612 import { openDb } from "./db.ts";
713 import type { Who } from "./who.ts";
814
@@ -12,47 +18,142 @@ const alice: Who = { role: "user", id: "u1", name: "alice" };
1218 const bob: Who = { role: "user", id: "u2", name: "bob" };
1319 const root: Who = { role: "admin", id: "u0", name: "root" };
1420
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);
1835 expect(refused.ok).toBe(false);
1936 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");
2864 });
2965
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);
3499 if (!r.ok) expect(r.error).toBe("invalid");
35100 }
101 expect(made(alice, { body: "x".repeat(2000) }).body.length).toBe(2000);
36102 });
37103
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 });
42112 expect(byBob.ok).toBe(false);
43113 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 });
45115 expect(byAnon.ok).toBe(false);
46116 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");
51117
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 });
56133 expect(odd.ok).toBe(false);
57134 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);
58159 });
src/actions.ts +165 −39
@@ -10,15 +10,38 @@ import type { Database } from "bun:sqlite";
1010 import { emit } from "./live.ts";
1111 import type { Who } from "./who.ts";
1212
13 export interface Note {
13 export type State = "open" | "decided" | "withdrawn";

p

14
15 export interface Proposal {
1416 readonly id: number;
17 readonly title: string;
1518 readonly body: string;
19 readonly rule: string;
20 readonly state: State;
21 readonly decided_option_id: number | null;
1622 readonly author_id: string;
1723 readonly author_name: string;
1824 readonly created_at: string;
1925 readonly updated_at: string;
2026 }
2127
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
2245 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
2346 export type Result<T> =
2447 | { ok: true; value: T }
@@ -30,64 +53,167 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({
3053 message,
3154 });
3255
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"];
3464
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(
3669 db: Database,
3770 _who: Who,
38 input: { limit?: number } = {},
39 ): Result<Note[]> {
71 input: { state?: string | undefined; limit?: number | undefined } = {},
72 ): Result<ProposalSummary[]> {
4073 const limit = Math.min(
4174 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
4275 500,
4376 );
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(", ")}.`);
4480 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),
4894 );
4995 }
5096
51 export function addNote(
97 export function getProposal(
5298 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
62109 .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 });
69115 }
70116
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(
72180 db: Database,
73181 who: Who,
74182 input: { id: number },
75 ): Result<{ id: number }> {
183 ): Result<Proposal> {
76184 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.");
78186 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")
82190 .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);
89210 }
90211
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";
12 import { expect, test } from "bun:test";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
@@ -21,3 +22,65 @@ test("migrations apply once, in order, and the file checks out", () => {
2122 ?.foreign_keys,
2223 ).toBe(1);
2324 });
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[] = [
2424 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
2525 )`,
2626 `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`,
2752 ];
2853
2954 export function openDb(dir = dataDir): Database {
src/http.ts +8 −4
@@ -1,6 +1,6 @@
11 /**
22 * 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.
44 */
55
66 import { createHash } from "node:crypto";
@@ -58,12 +58,16 @@ export const text = (body: string, status = 200): Response =>
5858 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
5959 });
6060
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 =>
6367 new Response(null, {
6468 status: 303,
6569 headers: {
66 location: "/",
70 location,
6771 ...SECURITY,
6872 ...(notice === undefined
6973 ? {}
src/mcp.ts +73 −19
@@ -7,7 +7,20 @@
77 */
88
99 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";
1124 import { title } from "./env.ts";
1225 import { fields } from "./http.ts";
1326 import type { Who } from "./who.ts";
@@ -25,11 +38,17 @@ interface Tool {
2538
2639 export const tools: readonly Tool[] = [
2740 {
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.",
3044 inputSchema: {
3145 type: "object",
3246 properties: {
47 state: {
48 type: "string",
49 enum: [...STATES],
50 description: "Only proposals in this state (all states by default).",
51 },
3352 limit: {
3453 type: "integer",
3554 minimum: 1,
@@ -39,28 +58,63 @@ export const tools: readonly Tool[] = [
3958 },
4059 },
4160 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 }),
4365 },
4466 {
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.",
4869 inputSchema: {
4970 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 } },
6172 required: ["id"],
6273 },
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) }),
64118 },
65119 ];
66120
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&#10;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 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 addNote,
10 deleteNote,
11 listNotes,
9 getProposal,
10 listProposals,
11 propose,
12 withdrawProposal,
1213 type Failure,
1314 type Result,
1415 } from "./actions.ts";
@@ -25,7 +26,9 @@ import {
2526 } from "./http.ts";
2627 import { events } from "./live.ts";
2728 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";
2932 import type { Who } from "./who.ts";
3033
3134 export interface Context {
@@ -37,7 +40,7 @@ export interface Context {
3740 }
3841
3942 export interface Route {
40 readonly method: "GET" | "POST" | "DELETE";
43 readonly method: "GET" | "POST";
4144 readonly path: string;
4245 readonly handle: (ctx: Context) => Response | Promise<Response>;
4346 }
@@ -58,20 +61,56 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
5861 STATUS[result.error],
5962 );
6063
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) : [];
6484
6585 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.
6787 {
6888 method: "GET",
6989 path: "/",
7090 handle: (c) => {
7191 const { notice, clear } = readNotice(c.request);
72 const notes = listNotes(c.db, c.who);
92 const proposals = listProposals(c.db, c.who, { state: "open" });
7393 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 }),
75114 200,
76115 clear,
77116 );
@@ -79,48 +118,72 @@ export const routes: readonly Route[] = [
79118 },
80119 {
81120 method: "POST",
82 path: "/notes",
121 path: "/proposals",
83122 handle: async (c) => {
84123 const form = await readForm(c.request);
85124 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, "/");
89134 },
90135 },
91136 {
92137 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 ),
95144 },
96145
97146 // The JSON API: the same actions, the same identity, plain status codes.
98147 {
99148 method: "GET",
100 path: "/api/notes",
149 path: "/api/proposals",
101150 handle: (c) =>
102151 answer(
103 listNotes(c.db, c.who, {
152 listProposals(c.db, c.who, {
153 state: c.url.searchParams.get("state") ?? undefined,
104154 limit: Number(c.url.searchParams.get("limit") ?? 100),
105155 }),
106156 ),
107157 },
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 },
108164 {
109165 method: "POST",
110 path: "/api/notes",
166 path: "/api/proposals",
111167 handle: async (c) => {
112168 const body = await readJson(c.request);
113169 if (body === undefined) return json({ error: "too_large" }, 413);
170 const input = fields(body);
114171 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 }),
116178 201,
117179 );
118180 },
119181 },
120182 {
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) })),
124187 },
125188
126189 // 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> = {}) =>
4646 api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
4747 (r) => r.json() as Promise<any>,
4848 );
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 };
4964
5065 test("the page knows who you are and offers the right door", async () => {
5166 const guest = await (await fetch(base + "/")).text();
5267 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"');
5570 const signed = await (await api("/", {}, alice)).text();
5671 expect(signed).toContain("<b>alice</b>");
5772 expect(signed).toContain("/__forge/signout");
58 expect(signed).toContain('action="/notes"');
73 expect(signed).toContain('action="/proposals"');
5974 });
6075
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 () => {
6277 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", {
6582 method: "POST",
6683 body: form,
6784 redirect: "manual",
6885 });
6986 expect(refused.status).toBe(303);
87 expect(refused.headers.get("location")).toBe("/");
7088 expect(refused.headers.get("set-cookie")).toContain("notice=");
7189 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
7592 const posted = await api(
76 "/notes",
93 "/proposals",
7794 { method: "POST", body: form, redirect: "manual" },
7895 alice,
7996 );
8097 expect(posted.status).toBe(303);
8198 expect(posted.headers.get("set-cookie")).toBeNull();
99 const to = posted.headers.get("location") ?? "";
100 expect(to).toMatch(/^\/p\/\d+$/);
101
82102 const page = await (await api("/", {}, alice)).text();
83 expect(page).toContain("hello from a form");
103 expect(page).toContain("Paint the shed");
84104 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 &lt;script&gt;");
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 );
85178 });
86179
87180 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 {
97184 id: number;
185 option_count: number;
98186 }[];
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 ]);
100196 expect(
101 (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) }))
197 (await api("/api/proposals", { method: "POST", ...jsonBody(proposal) }))
102198 .status,
103199 ).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);
113200 expect(
114201 (
115202 await api(
116 "/api/notes",
117 { method: "POST", ...jsonBody({ body: "" }) },
203 "/api/proposals",
204 { method: "POST", ...jsonBody({ ...proposal, options: ["One"] }) },
118205 alice,
119206 )
120207 ).status,
121208 ).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);
122226 });
123227
124228 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 () =>
131235 expect(hello.result.capabilities).toEqual({ tools: {} });
132236 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
133237 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",
137242 ]);
138243 const call = (name: string, args: unknown, who = {}) =>
139244 rpc(
@@ -145,18 +250,22 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
145250 },
146251 who,
147252 );
148 const refused = await call("add_note", { body: "as nobody" });
253 const refused = await call("propose", proposal);
149254 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,
156261 );
157 expect(notes.map((n: { id: number }) => n.id)).toContain(note.id);
262 expect(all.map((p: { id: number }) => p.id)).toContain(one.id);
158263 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,
160269 ).toBeUndefined();
161270 expect(
162271 (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",
181290 if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type };
182291 }
183292 };
184 await api(
185 "/api/notes",
186 { method: "POST", ...jsonBody({ body: "live" }) },
187 alice,
188 );
293 const made = await open(alice, { title: "Live" });
189294 const heard = await next();
190 expect(heard.type).toBe("note.added");
295 expect(heard.type).toBe("proposal.opened");
191296 await reader.cancel();
192297 const late = await fetch(base + "/events", {
193298 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",
196301 (await late.body!.getReader().read()).value,
197302 );
198303 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"');
200318 });
201319
202320 test("writes are rationed per person", async () => {
203321 const flood = as("u9", "flood");
204322 const answers = await Promise.all(
205 Array.from({ length: 40 }, () =>
323 Array.from({ length: 40 }, (_, i) =>
206324 api(
207 "/api/notes",
208 { method: "POST", ...jsonBody({ body: "again" }) },
325 "/api/proposals",
326 {
327 method: "POST",
328 ...jsonBody({ ...proposal, title: `Again ${i}` }),
329 },
209330 flood,
210331 ),
211332 ),
@@ -222,13 +343,15 @@ test("stands on the security floor", async () => {
222343 );
223344 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
224345 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 ),
226349 ).toContain("default-src");
227350 });
228351
229352 test("a body is capped by the bytes read, whatever it claims", async () => {
230353 const response = await api(
231 "/notes",
354 "/proposals",
232355 {
233356 method: "POST",
234357 headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -247,9 +370,11 @@ test("HEAD sees the app up", async () => {
247370
248371 test("refuses a cross-site post", async () => {
249372 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");
251376 const posted = await api(
252 "/notes",
377 "/proposals",
253378 {
254379 method: "POST",
255380 body: form,
@@ -262,6 +387,9 @@ test("refuses a cross-site post", async () => {
262387 alice,
263388 );
264389 expect(posted.status).toBe(403);
390 expect(await (await fetch(base + "/")).text()).not.toContain(
391 "From another site",
392 );
265393 });
266394
267395 test("static files are cached for a year when linked by content", async () => {
src/tty.ts +185 −55
@@ -1,16 +1,20 @@
11 /**
22 * The app in a terminal. The forge runs this on a pty when someone
33 * `ssh`es to the app, over the same data as the page and through the same
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.
67 */
78
89 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,
1418 } from "./actions.ts";
1519 import { openDb } from "./db.ts";
1620 import { ssh, title, where } from "./env.ts";
@@ -25,48 +29,158 @@ const out = (s: string): void => {
2529 process.stdout.write(s);
2630 };
2731
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
2841 let cursor = 0;
29 let typing: string | undefined;
42 let open: number | undefined;
43 let draft: Draft | undefined;
3044 let said = "";
3145
32 const notes = (): Note[] => {
33 const listed = listNotes(db, me);
46 const proposals = (): ProposalSummary[] => {
47 const listed = listProposals(db, me, { state: "open" });
3448 return listed.ok ? listed.value : [];
3549 };
3650
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
3774 function draw(): void {
38 const rows = notes();
3975 const lines = [
4076 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
4177 ssh === undefined ? "" : dim(`connect: ${ssh}`),
4278 "",
4379 ];
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());
5883 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 [
6392 dim(
6493 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.",
6796 ),
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}`),
68130 );
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 }
70184 }
71185
72186 process.stdin.setRawMode?.(true);
@@ -75,15 +189,31 @@ process.stdin.setEncoding("utf8");
75189 draw();
76190 process.stdin.on("data", (key: string) => {
77191 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;
87217 draw();
88218 return;
89219 }
@@ -91,25 +221,25 @@ process.stdin.on("data", (key: string) => {
91221 out(`${esc}2J${esc}H`);
92222 process.exit(0);
93223 }
94 const rows = notes();
224 const rows = proposals();
225 const under = open ?? rows[cursor]?.id;
95226 if (key === "j" || key === "\x1b[B")
96227 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
97228 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;
101235 }
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: "" };
112241 }
242 if (key === "w") withdraw(under);
113243 draw();
114244 });
115245 process.stdout.on("resize", draw);
static/app.css +77 −4
@@ -127,7 +127,26 @@ code,
127127 margin: 0;
128128 padding: 0;
129129 }
130 .note {
130 .stack {
131 display: grid;
132 gap: var(--s3);
133 }
134 .field {
135 display: grid;
136 gap: var(--s1);
137 }
138 .field > span {
139 font-size: var(--text-sm);
140 font-weight: 600;
141 }
142 .small {
143 font-size: var(--text-sm);
144 }
145 .plain {
146 color: inherit;
147 text-decoration: none;
148 }
149 .proposal {
131150 display: flex;
132151 justify-content: space-between;
133152 align-items: flex-start;
@@ -135,15 +154,55 @@ code,
135154 padding: var(--s3) 0;
136155 border-top: 1px solid var(--line);
137156 }
138 .note:first-child {
157 .proposal:first-child {
139158 border-top: 0;
140159 }
141 .note p {
160 .proposal p {
142161 overflow-wrap: anywhere;
143162 }
144 .note small {
163 .proposal small {
145164 font-size: var(--text-sm);
146165 }
166 .title {
167 font-size: var(--text-lg);
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(--s3);
181 }
182 .head-row h2 {
183 margin: 0;
184 font-size: var(--text-lg);
185 line-height: 1.3;
186 overflow-wrap: anywhere;
187 }
188 .body {
189 margin: var(--s3) 0;
190 white-space: pre-wrap;
191 overflow-wrap: anywhere;
192 }
193 .options {
194 margin: 0 0 var(--s4);
195 padding-left: 1.4rem;
196 display: grid;
197 gap: var(--s1);
198 }
199 .options li {
200 overflow-wrap: anywhere;
201 }
202 .options .chosen {
203 font-weight: 650;
204 color: var(--accent);
205 }
147206 .empty {
148207 padding: var(--s6) var(--s4);
149208 text-align: center;
@@ -173,6 +232,12 @@ footer code {
173232 border: 1px solid var(--line);
174233 border-radius: var(--radius);
175234 }
235 textarea.input {
236 min-height: calc(2 * var(--tap));
237 padding: var(--s2) var(--s3);
238 line-height: 1.5;
239 resize: vertical;
240 }
176241 .input::placeholder {
177242 color: var(--muted);
178243 }
@@ -217,6 +282,14 @@ footer code {
217282 color: var(--accent);
218283 border-color: currentColor;
219284 }
285 .pill[data-state="open"] {
286 color: var(--accent);
287 border-color: currentColor;
288 }
289 .pill[data-state="withdrawn"] {
290 color: var(--danger);
291 border-color: currentColor;
292 }
220293
221294 /* A word from the last request, shown once, gone in a moment. */
222295 .toast {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs