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

A first visit explains how this group decides, instead of an empty page #30

Merged opened this

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

Merged

hearth/quorum:hearth/20-a-first-visit-explains-how-this-group-de → main 1 commit fork

  • Merged. hearth/20-a-first-visit-explains-how-this-group-de was merged into main as 52be92a.

Review

Sign in to review this change.
7 files changed +311 −13
README.md +11 −2
@@ -30,7 +30,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
3030 | `src/live.ts` | the event log, the bus, and `GET /events` |
3131 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
3232 | `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 |
3434 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
3535 | `src/server.ts` | boot: open the database, serve |
3636 | `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
6161 in order, and a Withdraw form for its author or an admin while it is open.
6262 The composer picks the rule from a select, with one field beside it for the
6363 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.
6574
6675 ```sh
6776 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: {
5656 </form>`;
5757 const list =
5858 proposals.length === 0
59 ? `<p class="empty">No proposals yet. What is written here stays in ${escape(where)} data.</p>`
59 ? empty()
6060 : `<ul class="list">${proposals.map(item).join("")}</ul>`;
6161 return layout(
6262 `<section class="card">${composer}</section>
@@ -66,6 +66,28 @@ export function home(input: {
6666 );
6767 }
6868
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
6991 const item = (proposal: ProposalSummary): string => `<li class="proposal">
7092 <div>
7193 <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 {
4141 <footer class="muted">
4242 ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
4343 <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>
4445 </footer>
4546 </div>
4647 </body>
src/routes.ts +4
@@ -27,6 +27,7 @@ import {
2727 } from "./http.ts";
2828 import { events } from "./live.ts";
2929 import { rpc } from "./mcp.ts";
30 import { about } from "./pages/about.ts";
3031 import { home } from "./pages/home.ts";
3132 import { missing } from "./pages/layout.ts";
3233 import { proposalPage } from "./pages/proposal.ts";
@@ -109,6 +110,9 @@ export const routes: readonly Route[] = [
109110 );
110111 },
111112 },
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 })) },
112116 {
113117 method: "GET",
114118 path: "/p/:id",
src/server.test.ts +102 −6
@@ -5,6 +5,9 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { migrations } from "./db.ts";
77 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";
811 import { serve } from "./server.ts";
912 import { sign } from "./who.ts";
1013
@@ -55,12 +58,16 @@ const proposal = {
5558 options: ["The kitchen", "The garden"],
5659 };
5760 /** 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 });
6471 expect(created.status).toBe(201);
6572 return (await created.json()) as { id: number; author_name: string };
6673 };
@@ -76,6 +83,95 @@ test("the page knows who you are and offers the right door", async () => {
7683 expect(signed).toContain('action="/proposals"');
7784 });
7885
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
79175 test("a form post proposes as the person and lands on the proposal", async () => {
80176 const form = new FormData();
81177 form.set("title", "Paint the shed");
static/app.css +39 −4
@@ -218,12 +218,47 @@ code,
218218 font-weight: 650;
219219 color: var(--accent);
220220 }
221 /* Nothing to show yet: the same card, drawn as an outline, saying what this
222 app is rather than that it is empty. */
221223 .empty {
222 padding: var(--s6) var(--s4);
223 text-align: center;
224 display: grid;
225 gap: var(--s3);
226 border-style: dashed;
227 margin-bottom: 0;
228 }
229 .empty h2,
230 .prose h2 {
231 margin: 0;
232 font-size: var(--text-lg);
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(--s3);
240 }
241 .prose h3 {
242 margin: 0 0 var(--s2);
243 font-size: var(--text-sm);
244 text-transform: uppercase;
245 letter-spacing: 0.04em;
224246 color: var(--muted);
225 border: 1px dashed var(--line);
226 border-radius: var(--radius);
247 }
248 .prose ul {
249 margin: 0;
250 padding-left: 1.2rem;
251 display: grid;
252 gap: var(--s2);
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(--s4);
261 grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
227262 }
228263 footer {
229264 margin-top: var(--s6);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs