Public: anyone can read this; the crew can change it.
A first visit explains how this group decides, instead of an empty page #30
Check · Review · Land · Live done — Landed and live in production.
hearth/quorum:hearth/20-a-first-visit-explains-how-this-group-de → main 1 commit fork
7 files
changed +311
−13
README.md +11 −2
| @@ -30,7 +30,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 30 | 30 | | `src/live.ts` | the event log, the bus, and `GET /events` | |
| 31 | 31 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 32 | 32 | | `src/routes.ts` | every path, in one table | |
| 33 | | `src/pages/` | the HTML, rendered on the server: one layout, two pages | | |
| 33 | | `src/pages/` | the HTML, rendered on the server: one layout, three pages | | |
| 34 | 34 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 35 | 35 | | `src/server.ts` | boot: open the database, serve | |
| 36 | 36 | | `src/tty.ts` | the terminal, over the same actions | |
| @@ -61,7 +61,16 @@ whoever is signed in. `/p/<id>` is one proposal: the paragraph, the options | ||
| 61 | 61 | in order, and a Withdraw form for its author or an admin while it is open. |
| 62 | 62 | The composer picks the rule from a select, with one field beside it for the |
| 63 | 63 | names and count a `named:` rule needs; `/p/<id>` says the rule in words above |
| 64 | the options. A guest sees both pages and no forms. | |
| 64 | the options. A guest sees both pages and no forms. With nothing proposed yet, | |
| 65 | `/` says in four sentences what a proposal is rather than that it is empty. | |
| 66 | ||
| 67 | `/about`, linked from the footer of every page, is the longer version: how a | |
| 68 | proposal is decided, the three rules printed from `src/quorum.ts`, two plain | |
| 69 | lists of what is built and what is not (voting, deliberation, a roster and | |
| 70 | bindings are not), and the four doors — this page, `/api/proposals`, | |
| 71 | `POST /mcp` and the terminal, in the exact words of `FORGE_APP_SSH` or `off`. | |
| 72 | Only the prose there is written by hand: every fact it states is read from | |
| 73 | the module that owns it, so it rots when a module changes and not before. | |
| 65 | 74 | |
| 66 | 75 | sh |
| 67 | 76 | curl https://<app>/api/proposals # every proposal, newest first |
src/pages/about.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * What this app is, for someone who just arrived: how a proposal is decided, | |
| 3 | * what is built and what is not, and every door in. Only the prose here is | |
| 4 | * written by hand — the rules come from `src/quorum.ts` and the terminal from | |
| 5 | * `src/env.ts`, so a page that rots is a module that changed under it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { ssh, where } from "../env.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import { RULES, RULE_FORMS } from "../quorum.ts"; | |
| 11 | import type { Who } from "../who.ts"; | |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | ||
| 14 | /** One rule as a page prints it: whatever `RULES` holds, never a copy of it. */ | |
| 15 | interface RuleEntry { | |
| 16 | readonly label: string; | |
| 17 | readonly description: string; | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * What a person can do here today. One line per thing that works, in the | |
| 22 | * order someone meets them — kept true by whoever changes the behaviour. | |
| 23 | */ | |
| 24 | const WORKS: readonly string[] = [ | |
| 25 | "Open a proposal: a title, a paragraph, two to eight options, and the rule that will decide it.", | |
| 26 | "Read every proposal and any one of them, signed in or not.", | |
| 27 | "Withdraw a proposal you opened while it is open; an admin withdraws any.", | |
| 28 | "Watch the list stay current as other people write, without reloading.", | |
| 29 | ]; | |
| 30 | ||
| 31 | /** | |
| 32 | * What the direction says and the code does not do yet. Whoever lands one of | |
| 33 | * these moves its line up into `WORKS`; an empty promise on a page is worse | |
| 34 | * than no page. | |
| 35 | */ | |
| 36 | const NOT_YET: readonly string[] = [ | |
| 37 | "Voting. The counting in the quorum module is written and tested, but nothing casts a ballot yet, so nothing is decided yet either.", | |
| 38 | "Deliberation. There is no way to ask a question or argue a point on a proposal before it decides.", | |
| 39 | "A roster. Who counts as a member of this group has no page and no list; a “named few” rule is the only way to say who counts.", | |
| 40 | "Bindings. A decision is a record here and does nothing elsewhere — no webhook, no issue, no file.", | |
| 41 | ]; | |
| 42 | ||
| 43 | export function about(input: { who: Who }): string { | |
| 44 | const { who } = input; | |
| 45 | return layout( | |
| 46 | `<section class="card prose"> | |
| 47 | <h2>How this works</h2> | |
| 48 | <p>A proposal is a paragraph and the options it asks the group to choose | |
| 49 | between: what is being decided, why, and the ways it could go. Anyone | |
| 50 | signed in to the forge may open one, and the members of the group vote on | |
| 51 | its options. Each proposal carries the rule that will decide it, chosen | |
| 52 | when it is opened, and it is decided the moment that rule is met — not by | |
| 53 | anybody closing it. A guest signs in to nothing and still reads every | |
| 54 | public proposal, its options and, once there are decisions, how each one | |
| 55 | was reached.</p> | |
| 56 | <p class="muted">${youAre(who)}</p> | |
| 57 | </section> | |
| 58 | <section class="card prose"> | |
| 59 | <h2>How a proposal is decided</h2> | |
| 60 | <ul>${rulesList()}</ul> | |
| 61 | <p class="muted small">Over the API, in MCP and at the terminal a rule is | |
| 62 | one line of text: ${escape(RULE_FORMS)}.</p> | |
| 63 | </section> | |
| 64 | <section class="card prose"> | |
| 65 | <h2>What is built, and what is not</h2> | |
| 66 | <div class="two"> | |
| 67 | <div> | |
| 68 | <h3>Working today</h3> | |
| 69 | <ul>${items(WORKS)}</ul> | |
| 70 | </div> | |
| 71 | <div> | |
| 72 | <h3>Not yet</h3> | |
| 73 | <ul>${items(NOT_YET)}</ul> | |
| 74 | </div> | |
| 75 | </div> | |
| 76 | </section> | |
| 77 | <section class="card prose"> | |
| 78 | <h2>Four doors, the same rules</h2> | |
| 79 | <ul> | |
| 80 | <li><a href="/">This page</a> — the open proposals, and the composer for | |
| 81 | whoever is signed in.</li> | |
| 82 | <li><a href="/api/proposals">/api/proposals</a> — the same proposals as | |
| 83 | JSON, and the same identity deciding what you may do.</li> | |
| 84 | <li><code>POST /mcp</code> — the same operations as MCP tools, for an | |
| 85 | agent.</li> | |
| 86 | <li>${terminal()}</li> | |
| 87 | </ul> | |
| 88 | <p class="muted small">Whichever door you come through, a rule holds the | |
| 89 | same: the doors read and write, they decide nothing on their own. This is | |
| 90 | ${escape(where)}.</p> | |
| 91 | </section>`, | |
| 92 | who, | |
| 93 | "/about", | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * The three rules as the quorum module spells them. A caller passes nothing | |
| 99 | * and gets `RULES`; a test passes its own list and sees it printed, which is | |
| 100 | * what proves this page has no second copy of that text. | |
| 101 | */ | |
| 102 | export const rulesList = (rules: readonly RuleEntry[] = RULES): string => | |
| 103 | rules | |
| 104 | .map( | |
| 105 | (rule) => | |
| 106 | `<li><b>${escape(rule.label)}</b> — ${escape(rule.description)}</li>`, | |
| 107 | ) | |
| 108 | .join(""); | |
| 109 | ||
| 110 | /** | |
| 111 | * What the person reading can do right now, and nothing they cannot. An | |
| 112 | * admin gets one line more: the roster is theirs to keep, and it is the piece | |
| 113 | * a quorum is measured against. | |
| 114 | */ | |
| 115 | const youAre = (who: Who): string => { | |
| 116 | if (who.role === "anon") | |
| 117 | return "You are reading as a guest: everything public here is yours to read, and signing in is what it takes to open a proposal."; | |
| 118 | const you = `Signed in as ${escape(who.name)}. You can open a proposal from the front page, pick the rule that decides it, and withdraw your own while it is open.`; | |
| 119 | return who.role === "admin" | |
| 120 | ? `${you} As an admin you can withdraw anybody’s proposal, and the roster — who counts toward a quorum — is yours to keep once it is built; until then a “named few” rule is the only place a proposal names who counts.` | |
| 121 | : you; | |
| 122 | }; | |
| 123 | ||
| 124 | /** The terminal, in the exact words the forge gave this process — or that it is off. */ | |
| 125 | const terminal = (): string => | |
| 126 | ssh === undefined | |
| 127 | ? "The terminal — off in this environment; where it runs, the forge gives the exact command." | |
| 128 | : `The terminal — <code>${escape(ssh)}</code>, the same proposals on a keyboard.`; | |
| 129 | ||
| 130 | const items = (lines: readonly string[]): string => | |
| 131 | lines.map((line) => `<li>${escape(line)}</li>`).join(""); |
src/pages/home.ts +23 −1
| @@ -56,7 +56,7 @@ export function home(input: { | ||
| 56 | 56 | </form>`; |
| 57 | 57 | const list = |
| 58 | 58 | proposals.length === 0 |
| 59 | ? `<p class="empty">No proposals yet. What is written here stays in ${escape(where)} data.</p>` | |
| 59 | ? empty() | |
| 60 | 60 | : `<ul class="list">${proposals.map(item).join("")}</ul>`; |
| 61 | 61 | return layout( |
| 62 | 62 | `<section class="card">${composer}</section> |
| @@ -66,6 +66,28 @@ export function home(input: { | ||
| 66 | 66 | ); |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /** | |
| 70 | * Nothing to show is the first thing most people see, so it says what this | |
| 71 | * app is in four sentences and hands the long version to `/about`. The rules | |
| 72 | * are named from `RULES`, never spelled again here. | |
| 73 | */ | |
| 74 | const empty = (): string => `<section class="card empty"> | |
| 75 | <h2>No proposals yet</h2> | |
| 76 | <p>A proposal is a paragraph and the options it asks the group to choose | |
| 77 | between. Anyone signed in may open one, and the members of the group vote on | |
| 78 | its options. It is decided the moment its quorum rule is met — ${names()}, | |
| 79 | whichever the proposer picked. A guest reads every public proposal without | |
| 80 | signing in.</p> | |
| 81 | <div class="row"><a class="button button-quiet" href="/about">How this works</a></div> | |
| 82 | <p class="muted small">What is written here stays in ${escape(where)} data.</p> | |
| 83 | </section>`; | |
| 84 | ||
| 85 | /** The three rules by name, as a clause: “Majority, Everyone or A named few”. */ | |
| 86 | const names = (): string => { | |
| 87 | const labels = RULES.map((rule) => `<b>${escape(rule.label)}</b>`); | |
| 88 | return `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`; | |
| 89 | }; | |
| 90 | ||
| 69 | 91 | const item = (proposal: ProposalSummary): string => `<li class="proposal"> |
| 70 | 92 | <div> |
| 71 | 93 | <p><a class="title" href="/p/${proposal.id}">${escape(proposal.title)}</a></p> |
src/pages/layout.ts +1
| @@ -41,6 +41,7 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 41 | 41 | <footer class="muted"> |
| 42 | 42 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 43 | 43 | <p>Also at <a href="/api/proposals">/api/proposals</a> and <code>POST /mcp</code>, with the same rules.</p> |
| 44 | <p><a href="/about">How this group decides</a> — what a proposal is, what is built and what is not.</p> | |
| 44 | 45 | </footer> |
| 45 | 46 | </div> |
| 46 | 47 | </body> |
src/routes.ts +4
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | } from "./http.ts"; |
| 28 | 28 | import { events } from "./live.ts"; |
| 29 | 29 | import { rpc } from "./mcp.ts"; |
| 30 | import { about } from "./pages/about.ts"; | |
| 30 | 31 | import { home } from "./pages/home.ts"; |
| 31 | 32 | import { missing } from "./pages/layout.ts"; |
| 32 | 33 | import { proposalPage } from "./pages/proposal.ts"; |
| @@ -109,6 +110,9 @@ export const routes: readonly Route[] = [ | ||
| 109 | 110 | ); |
| 110 | 111 | }, |
| 111 | 112 | }, |
| 113 | // What this app is, for a first visit: prose, and every fact in it read | |
| 114 | // from the module that owns it. | |
| 115 | { method: "GET", path: "/about", handle: (c) => html(about({ who: c.who })) }, | |
| 112 | 116 | { |
| 113 | 117 | method: "GET", |
| 114 | 118 | path: "/p/:id", |
src/server.test.ts +102 −6
| @@ -5,6 +5,9 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { migrations } from "./db.ts"; |
| 7 | 7 | import { warnings } from "./env.ts"; |
| 8 | import { escape } from "./http.ts"; | |
| 9 | import { rulesList } from "./pages/about.ts"; | |
| 10 | import { RULES, RULE_FORMS } from "./quorum.ts"; | |
| 8 | 11 | import { serve } from "./server.ts"; |
| 9 | 12 | import { sign } from "./who.ts"; |
| 10 | 13 | |
| @@ -55,12 +58,16 @@ const proposal = { | ||
| 55 | 58 | options: ["The kitchen", "The garden"], |
| 56 | 59 | }; |
| 57 | 60 | /** A proposal over the API, for the tests that need one to look at. */ |
| 58 | const open = async (who: Record<string, string>, over: unknown = {}) => { | |
| 59 | const created = await api( | |
| 60 | "/api/proposals", | |
| 61 | { method: "POST", ...jsonBody({ ...proposal, ...(over as object) }) }, | |
| 62 | who, | |
| 63 | ); | |
| 61 | const open = async ( | |
| 62 | who: Record<string, string>, | |
| 63 | over: unknown = {}, | |
| 64 | at: string = base, | |
| 65 | ) => { | |
| 66 | const created = await fetch(at + "/api/proposals", { | |
| 67 | method: "POST", | |
| 68 | ...jsonBody({ ...proposal, ...(over as object) }), | |
| 69 | headers: { "content-type": "application/json", ...who }, | |
| 70 | }); | |
| 64 | 71 | expect(created.status).toBe(201); |
| 65 | 72 | return (await created.json()) as { id: number; author_name: string }; |
| 66 | 73 | }; |
| @@ -76,6 +83,95 @@ test("the page knows who you are and offers the right door", async () => { | ||
| 76 | 83 | expect(signed).toContain('action="/proposals"'); |
| 77 | 84 | }); |
| 78 | 85 | |
| 86 | test("an empty front page explains the app, and steps aside once there is one", async () => { | |
| 87 | // Its own server, so "empty" is the truth about this database and not a | |
| 88 | // matter of which test ran first. | |
| 89 | const fresh = serve({ | |
| 90 | port: 0, | |
| 91 | dataDir: mkdtempSync(join(tmpdir(), "app-empty-")), | |
| 92 | identityKey: KEY, | |
| 93 | }); | |
| 94 | const at = `http://${fresh.hostname}:${fresh.port}`; | |
| 95 | try { | |
| 96 | const page = await (await fetch(at + "/")).text(); | |
| 97 | expect(page).toContain("No proposals yet"); | |
| 98 | expect(page).toContain("A proposal is a paragraph"); | |
| 99 | expect(page).toContain("A guest reads every public proposal"); | |
| 100 | // The three rules are named, in the words the quorum module holds. | |
| 101 | for (const rule of RULES) expect(page).toContain(rule.label); | |
| 102 | // The short form links to the long one instead of repeating it. | |
| 103 | expect(page).toContain('href="/about"'); | |
| 104 | expect(page).not.toContain(RULES[0]!.description); | |
| 105 | ||
| 106 | await open(alice, { title: "Something to decide" }, at); | |
| 107 | const filled = await (await fetch(at + "/")).text(); | |
| 108 | expect(filled).toContain("Something to decide"); | |
| 109 | expect(filled).not.toContain("No proposals yet"); | |
| 110 | expect(filled).not.toContain("A proposal is a paragraph"); | |
| 111 | } finally { | |
| 112 | fresh.stop(true); | |
| 113 | } | |
| 114 | }); | |
| 115 | ||
| 116 | test("/about says what this is, from the modules that own the facts", async () => { | |
| 117 | const guest = await fetch(base + "/about"); | |
| 118 | expect(guest.status).toBe(200); | |
| 119 | expect(guest.headers.get("content-type")).toContain("text/html"); | |
| 120 | expect(guest.headers.get("content-security-policy")).toContain( | |
| 121 | "default-src 'self'", | |
| 122 | ); | |
| 123 | expect(guest.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 124 | const page = await guest.text(); | |
| 125 | // The rules are printed, not restated. | |
| 126 | for (const rule of RULES) { | |
| 127 | expect(page).toContain(rule.label); | |
| 128 | expect(page).toContain(escape(rule.description)); | |
| 129 | } | |
| 130 | expect(page).toContain(escape(RULE_FORMS)); | |
| 131 | // Four doors, the terminal in the forge's own words or off. | |
| 132 | expect(page).toContain('href="/api/proposals"'); | |
| 133 | expect(page).toContain("POST /mcp"); | |
| 134 | expect(page).toContain( | |
| 135 | process.env.FORGE_APP_SSH === undefined | |
| 136 | ? "off in this environment" | |
| 137 | : escape(process.env.FORGE_APP_SSH), | |
| 138 | ); | |
| 139 | // Honest about what is not built: voting, deliberation, bindings. | |
| 140 | for (const missing of ["Voting.", "Deliberation.", "Bindings."]) | |
| 141 | expect(page).toContain(missing); | |
| 142 | expect(page).toContain("reading as a guest"); | |
| 143 | expect(page).not.toContain("Signed in as"); | |
| 144 | ||
| 145 | const user = await api("/about", {}, alice); | |
| 146 | expect(user.status).toBe(200); | |
| 147 | const mine = await user.text(); | |
| 148 | expect(mine).toContain("Signed in as alice"); | |
| 149 | expect(mine).toContain("withdraw your own"); | |
| 150 | expect(mine).not.toContain("yours to keep"); | |
| 151 | ||
| 152 | const admin = await (await api("/about", {}, root)).text(); | |
| 153 | expect(admin).toContain("Signed in as root"); | |
| 154 | expect(admin).toContain("withdraw anybody’s proposal"); | |
| 155 | expect(admin).toContain("the roster — who counts toward a quorum"); | |
| 156 | ||
| 157 | // HEAD is GET without the body. | |
| 158 | const head = await fetch(base + "/about", { method: "HEAD" }); | |
| 159 | expect(head.status).toBe(200); | |
| 160 | expect(head.headers.get("content-type")).toContain("text/html"); | |
| 161 | expect(await head.text()).toBe(""); | |
| 162 | }); | |
| 163 | ||
| 164 | test("the rules on /about are whatever the quorum module says", () => { | |
| 165 | // Change the description in `src/quorum.ts` and the page changes with it: | |
| 166 | // nothing here holds a second copy of that sentence. | |
| 167 | const page = rulesList([ | |
| 168 | { label: "Whim", description: "Whoever shouts loudest and last." }, | |
| 169 | ]); | |
| 170 | expect(page).toContain("Whoever shouts loudest and last."); | |
| 171 | expect(page).toContain("<b>Whim</b>"); | |
| 172 | expect(rulesList()).toContain(escape(RULES[1]!.description)); | |
| 173 | }); | |
| 174 | ||
| 79 | 175 | test("a form post proposes as the person and lands on the proposal", async () => { |
| 80 | 176 | const form = new FormData(); |
| 81 | 177 | form.set("title", "Paint the shed"); |
static/app.css +39 −4
| @@ -218,12 +218,47 @@ code, | ||
| 218 | 218 | font-weight: 650; |
| 219 | 219 | color: var(); |
| 220 | 220 | } |
| 221 | /* Nothing to show yet: the same card, drawn as an outline, saying what this | |
| 222 | app is rather than that it is empty. */ | |
| 221 | 223 | .empty { |
| 222 | padding: var() var(); | |
| 223 | text-align: center; | |
| 224 | display: grid; | |
| 225 | gap: var(); | |
| 226 | border-style: dashed; | |
| 227 | margin-bottom: 0; | |
| 228 | } | |
| 229 | .empty h2, | |
| 230 | .prose h2 { | |
| 231 | margin: 0; | |
| 232 | font-size: var(); | |
| 233 | line-height: 1.3; | |
| 234 | } | |
| 235 | ||
| 236 | /* The about page: prose, two honest lists and the doors. */ | |
| 237 | .prose { | |
| 238 | display: grid; | |
| 239 | gap: var(); | |
| 240 | } | |
| 241 | .prose h3 { | |
| 242 | margin: 0 0 var(); | |
| 243 | font-size: var(); | |
| 244 | text-transform: uppercase; | |
| 245 | letter-spacing: 0.04em; | |
| 224 | 246 | color: var(); |
| 225 | border: 1px dashed var(); | |
| 226 | border-radius: var(); | |
| 247 | } | |
| 248 | .prose ul { | |
| 249 | margin: 0; | |
| 250 | padding-left: 1.2rem; | |
| 251 | display: grid; | |
| 252 | gap: var(); | |
| 253 | } | |
| 254 | .prose li { | |
| 255 | overflow-wrap: anywhere; | |
| 256 | } | |
| 257 | /* Two columns where there is room, one at 360px — and no shift either way. */ | |
| 258 | .two { | |
| 259 | display: grid; | |
| 260 | gap: var(); | |
| 261 | grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); | |
| 227 | 262 | } |
| 228 | 263 | footer { |
| 229 | 264 | margin-top: var(); |