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

The event, not the note: one table, two actions, and GET /api/events #17

Merged opened this

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

Merged

scout/relay:scout/4-the-event-not-the-note-one-table-two-act → main 3 commits fork

  • Merged. scout/4-the-event-not-the-note-one-table-two-act was merged into main as bbf8ef9.

Review

Sign in to review this change.
12 files changed +617 −463
README.md +41 −17
@@ -29,8 +29,8 @@ the page, the JSON API, the MCP server and the terminal all call it and
2929 decide nothing themselves. `AGENTS.md` says how to add to it.
3030
3131 | file | what it is |
32 | ---------------------------------- | ------------------------------------------------------------ |
33 | `src/actions.ts` | list, add, delete — and who may |
32 | ----------------------------------- | ------------------------------------------------------------ |
33 | `src/actions.ts` | post, list, get an event — and who may |
3434 | `src/who.ts` | who is asking, from the forge's signed headers |
3535 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3636 | `src/live.ts` | the in-process bus and `GET /events` |
@@ -43,32 +43,56 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
4343 | `src/env.ts` | what the forge told this process |
4444 | `static/app.css`, `static/live.js` | the look, and the list that stays current |
4545
46 ## An event
47
48 One table, `event`, and one row an app posts and anyone reads:
49
50 | column | meaning |
51 | ------------- | ----------------------------------------------------------------- |
52 | `source` | who posted it: lowercase letters, digits, hyphens, up to 32 chars |
53 | `kind` | what happened: dotted lowercase segments, e.g. `order.placed` |
54 | `subject` | the one-line headline, 1–120 characters |
55 | `summary` | the detail, 1–200 characters |
56 | `link` | optional, an absolute `http(s)` URL, up to 512 characters |
57 | `occurred_at` | when it happened, ISO 8601, defaults to now |
58 | `received_at` | when relay stored it |
59 | `dedupe` | optional idempotency key, unique per `source`: post it twice, get the same row back |
60
61 `subject` and `summary` refuse control characters and newlines rather than
62 stripping them, and a `link`'s scheme is checked here so no other door has
63 to remember.
64
4665 ## Who you are
4766
4867 The forge says who is asking on every request it proxies, and the app
49 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
68 verifies the signature (`src/who.ts`); nothing else is trusted. Four tiers:
5069
5170 - **anon** — anyone. Reads.
52 - **user** — signed in to the forge and able to see this app. Adds notes, as
53 themselves, and removes their own.
54 - **admin** — able to write this repository. Removes any note.
71 - **user** — signed in to the forge and able to see this app. Reads, same as
72 anon; posting an event is not for a person.
73 - **admin** — able to write this repository. Posts an event under any
74 `source` it names.
75 - **app** — an app on the forge whose signed request this app has verified
76 (its own issue); posts under its own `source` only, and cannot claim
77 another's.
5578
5679 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5780 `/__forge/signout`; the forge answers both on the app's own host.
5881
5982 ## Any interface
6083
61 The same actions, the same rules, at every door. A script is the person
62 whose forge grant it carries; a bare script is anonymous.
84 The same actions, the same rules, at every door.
6385
6486 ```sh
65 curl https://<app>/api/notes
66 curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}'
67 curl -X DELETE https://<app>/api/notes/1
87 curl https://<app>/api/events
88 curl https://<app>/api/events?source=shop&kind=order&limit=20
89 curl https://<app>/api/events/1
90 curl -X POST https://<app>/api/events -H 'content-type: application/json' \
91 -d '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in.","source":"shop"}'
6892 ```
6993
70 As an MCP server, for Claude Code or Claude Desktop — `list_notes`,
71 `add_note`, `delete_note`:
94 As an MCP server, for Claude Code or Claude Desktop — `list_events`,
95 `post_event`, `get_event`:
7296
7397 ```sh
7498 claude mcp add --transport http __TITLE__ https://<app>/mcp
@@ -80,14 +104,14 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp
80104 }
81105 ```
82106
83 The page stays current over `/events`: a note someone else adds or removes
84 appears without a reload. Without JavaScript it is the same page, one
85 reload behind.
107 The page stays current over `/events`: an event another app posts appears
108 without a reload. Without JavaScript it is the same page, one reload behind.
86109
87110 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
88111 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
89112 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
90 a real pty over the same data, as the account at the keyboard.
113 a real pty over the same data, as the account at the keyboard: j/k move
114 through the timeline, q leaves.
91115
92116 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
93117 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
src/actions.test.ts +166 −38
@@ -2,57 +2,185 @@ 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 { getEvent, listEvents, postEvent } from "./actions.ts";
66 import { openDb } from "./db.ts";
77 import type { Who } from "./who.ts";
88
99 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
1010 const anon: Who = { role: "anon" };
1111 const alice: Who = { role: "user", id: "u1", name: "alice" };
12 const bob: Who = { role: "user", id: "u2", name: "bob" };
12 const shop: Who = { role: "app", id: "shop", name: "Shop" };
1313 const root: Who = { role: "admin", id: "u0", name: "root" };
1414
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" });
18 expect(refused.ok).toBe(false);
19 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);
15 const good = { kind: "order.placed", subject: "Order #42", summary: "A new order came in." };
16
17 test("anyone reads; only an app or an admin posts, an app as its own source", () => {
18 expect(listEvents(db, anon)).toEqual({ ok: true, value: [] });
19 const asAnon = postEvent(db, anon, good);
20 expect(asAnon.ok).toBe(false);
21 if (!asAnon.ok) expect(asAnon.error).toBe("signin_required");
22 const asUser = postEvent(db, alice, good);
23 expect(asUser.ok).toBe(false);
24 if (!asUser.ok) expect(asUser.error).toBe("forbidden");
25 const posted = postEvent(db, shop, { ...good, source: "someone-else" });
26 expect(posted.ok).toBe(true);
27 if (!posted.ok) return;
28 expect(posted.value.source).toBe("shop");
29 expect(posted.value.kind).toBe("order.placed");
30 const all = listEvents(db, anon);
31 expect(all.ok && all.value.map((e) => e.id)).toContain(posted.value.id);
2832 });
2933
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 });
34 test("an admin names the source; without one it is invalid", () => {
35 const named = postEvent(db, root, { ...good, source: "billing" });
36 expect(named.ok).toBe(true);
37 if (named.ok) expect(named.value.source).toBe("billing");
38 const unnamed = postEvent(db, root, good);
39 expect(unnamed.ok).toBe(false);
40 if (!unnamed.ok) expect(unnamed.error).toBe("invalid");
41 const badPattern = postEvent(db, root, { ...good, source: "Not Valid!" });
42 expect(badPattern.ok).toBe(false);
43 if (!badPattern.ok) expect(badPattern.error).toBe("invalid");
44 });
45
46 test("kind is dotted lowercase segments, at most 64 characters", () => {
47 for (const kind of ["", "Order.Placed", "order..placed", "a.b.c.d.e", "x".repeat(65)]) {
48 const r = postEvent(db, shop, { ...good, kind });
49 expect(r.ok).toBe(false);
50 if (!r.ok) expect(r.error).toBe("invalid");
51 }
52 expect(postEvent(db, shop, { ...good, kind: "a" }).ok).toBe(true);
53 expect(postEvent(db, shop, { ...good, kind: "a.b.c.d" }).ok).toBe(true);
54 });
55
56 test("subject is 1 to 120 characters, no control characters or newlines", () => {
57 for (const subject of ["", "x".repeat(121), "line one\nline two", "bell\x07"]) {
58 const r = postEvent(db, shop, { ...good, subject });
3359 expect(r.ok).toBe(false);
3460 if (!r.ok) expect(r.error).toBe("invalid");
3561 }
3662 });
3763
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 });
42 expect(byBob.ok).toBe(false);
43 if (!byBob.ok) expect(byBob.error).toBe("forbidden");
44 const byAnon = deleteNote(db, anon, { id: mine.value.id });
45 expect(byAnon.ok).toBe(false);
46 if (!byAnon.ok) expect(byAnon.error).toBe("signin_required");
47 expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true);
48 const gone = deleteNote(db, alice, { id: mine.value.id });
49 expect(gone.ok).toBe(false);
50 if (!gone.ok) expect(gone.error).toBe("not_found");
51
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 });
56 expect(odd.ok).toBe(false);
57 if (!odd.ok) expect(odd.error).toBe("invalid");
64 test("summary is 1 to 200 characters, no control characters or newlines", () => {
65 for (const summary of ["", "x".repeat(201), "line one\nline two"]) {
66 const r = postEvent(db, shop, { ...good, summary });
67 expect(r.ok).toBe(false);
68 if (!r.ok) expect(r.error).toBe("invalid");
69 }
70 });
71
72 test("a link is an absolute http(s) URL, at most 512 characters", () => {
73 for (const link of [
74 "javascript:alert(1)",
75 "ftp://example.com/file",
76 "not a url",
77 "http://" + "x".repeat(512) + ".com",
78 ]) {
79 const r = postEvent(db, shop, { ...good, link });
80 expect(r.ok).toBe(false);
81 if (!r.ok) expect(r.error).toBe("invalid");
82 }
83 const ok = postEvent(db, shop, { ...good, link: "https://example.com/order/42" });
84 expect(ok.ok).toBe(true);
85 });
86
87 test("occurred_at is refused more than 5 minutes ahead or 30 days behind", () => {
88 const ahead = new Date(Date.now() + 6 * 60 * 1000).toISOString();
89 const r1 = postEvent(db, shop, { ...good, occurred_at: ahead });
90 expect(r1.ok).toBe(false);
91 if (!r1.ok) expect(r1.error).toBe("invalid");
92 const behind = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
93 const r2 = postEvent(db, shop, { ...good, occurred_at: behind });
94 expect(r2.ok).toBe(false);
95 if (!r2.ok) expect(r2.error).toBe("invalid");
96 const bad = postEvent(db, shop, { ...good, occurred_at: "not a date" });
97 expect(bad.ok).toBe(false);
98 if (!bad.ok) expect(bad.error).toBe("invalid");
99 const ok = postEvent(db, shop, {});
100 expect(ok.ok).toBe(false); // missing kind/subject/summary
101 });
102
103 test("dedupe is at most 64 characters of letters, digits, '.', '_' or '-'", () => {
104 const r = postEvent(db, shop, { ...good, dedupe: "not valid!" });
105 expect(r.ok).toBe(false);
106 if (!r.ok) expect(r.error).toBe("invalid");
107 const tooLong = postEvent(db, shop, { ...good, dedupe: "x".repeat(65) });
108 expect(tooLong.ok).toBe(false);
109 });
110
111 test("the same (source, dedupe) twice returns the stored event and writes no second row", () => {
112 const first = postEvent(db, shop, { ...good, dedupe: "order-42" });
113 expect(first.ok).toBe(true);
114 if (!first.ok) return;
115 const before = listEvents(db, anon, { source: "shop" });
116 const countBefore = before.ok ? before.value.length : -1;
117 const second = postEvent(db, shop, {
118 ...good,
119 subject: "Different subject entirely",
120 dedupe: "order-42",
121 });
122 expect(second.ok).toBe(true);
123 if (!second.ok) return;
124 expect(second.value.id).toBe(first.value.id);
125 expect(second.value.subject).toBe(first.value.subject);
126 const after = listEvents(db, anon, { source: "shop" });
127 expect(after.ok && after.value.length).toBe(countBefore);
128 // A different source may use the same dedupe key.
129 const other = postEvent(db, root, {
130 ...good,
131 source: "other-shop",
132 dedupe: "order-42",
133 });
134 expect(other.ok).toBe(true);
135 });
136
137 test("kind filters exactly or as a dotted prefix", () => {
138 postEvent(db, shop, { ...good, kind: "order.placed" });
139 postEvent(db, shop, { ...good, kind: "order" });
140 const prefix = listEvents(db, anon, { kind: "order" });
141 expect(prefix.ok).toBe(true);
142 if (prefix.ok) {
143 expect(prefix.value.every((e) => e.kind === "order" || e.kind.startsWith("order."))).toBe(true);
144 expect(prefix.value.some((e) => e.kind === "order.placed")).toBe(true);
145 }
146 const none = listEvents(db, anon, { kind: "ord" });
147 expect(none.ok).toBe(true);
148 if (none.ok) expect(none.value.length).toBe(0);
149 });
150
151 test("a filter failing its pattern is invalid, not silently ignored", () => {
152 const badSource = listEvents(db, anon, { source: "Not Valid!" });
153 expect(badSource.ok).toBe(false);
154 if (!badSource.ok) expect(badSource.error).toBe("invalid");
155 const badKind = listEvents(db, anon, { kind: "Not Valid!" });
156 expect(badKind.ok).toBe(false);
157 if (!badKind.ok) expect(badKind.error).toBe("invalid");
158 const badBefore = listEvents(db, anon, { before: 1.5 });
159 expect(badBefore.ok).toBe(false);
160 if (!badBefore.ok) expect(badBefore.error).toBe("invalid");
161 });
162
163 test("limit clamps to 1..200, default 50", () => {
164 for (let i = 0; i < 5; i++) postEvent(db, shop, { ...good, kind: "clamp.test" });
165 const zero = listEvents(db, anon, { kind: "clamp.test", limit: 0 });
166 expect(zero.ok && zero.value.length).toBe(1);
167 const huge = listEvents(db, anon, { kind: "clamp.test", limit: 99999 });
168 expect(huge.ok && huge.value.length).toBe(5);
169 const clampedHuge = listEvents(db, anon, { limit: 99999 });
170 expect(clampedHuge.ok).toBe(true);
171 const def = listEvents(db, anon, {});
172 expect(def.ok).toBe(true);
173 });
174
175 test("getEvent finds an event by id, 404 when absent", () => {
176 const posted = postEvent(db, shop, good);
177 if (!posted.ok) throw new Error(posted.message);
178 const found = getEvent(db, anon, { id: posted.value.id });
179 expect(found).toEqual({ ok: true, value: posted.value });
180 const missing = getEvent(db, anon, { id: posted.value.id + 1_000_000 });
181 expect(missing.ok).toBe(false);
182 if (!missing.ok) expect(missing.error).toBe("not_found");
183 const bad = getEvent(db, anon, { id: 1.5 });
184 expect(bad.ok).toBe(false);
185 if (!bad.ok) expect(bad.error).toBe("invalid");
58186 });
src/actions.ts +216 −55
@@ -10,13 +10,16 @@ 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 interface Event {
1414 readonly id: number;
15 readonly body: string;
16 readonly author_id: string;
17 readonly author_name: string;
18 readonly created_at: string;
19 readonly updated_at: string;
15 readonly source: string;
16 readonly kind: string;
17 readonly subject: string;
18 readonly summary: string;
19 readonly link: string | null;
20 readonly occurred_at: string;
21 readonly received_at: string;
22 readonly dedupe: string | null;
2023 }
2124
2225 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
@@ -30,64 +33,222 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({
3033 message,
3134 });
3235
33 export const BODY_MAX = 500;
36 export const KIND_RE = /^[a-z0-9]+(\.[a-z0-9]+){0,3}$/;
37 export const KIND_MAX = 64;
38 export const SOURCE_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
39 export const DEDUPE_RE = /^[A-Za-z0-9._-]{1,64}$/;
40 export const SUBJECT_MIN = 1;
41 export const SUBJECT_MAX = 120;
42 export const SUMMARY_MIN = 1;
43 export const SUMMARY_MAX = 200;
44 export const LINK_MAX = 512;
45 export const OCCURRED_AHEAD_MS = 5 * 60 * 1000;
46 export const OCCURRED_BEHIND_MS = 30 * 24 * 60 * 60 * 1000;
47 export const LIMIT_MIN = 1;
48 export const LIMIT_MAX = 200;
49 export const LIMIT_DEFAULT = 50;
3450
35 export function listNotes(
51 // Any control character, including a newline or tab — refused, never stripped.
52 const CONTROL_RE = /[\u0000-\u001f\u007f]/;
53
54 const isPlainText = (s: string, min: number, max: number): boolean =>
55 s.length >= min && s.length <= max && !CONTROL_RE.test(s);
56
57 export interface PostEventInput {
58 readonly kind?: unknown;
59 readonly subject?: unknown;
60 readonly summary?: unknown;
61 readonly link?: unknown;
62 readonly source?: unknown;
63 readonly occurred_at?: unknown;
64 readonly dedupe?: unknown;
65 }
66
67 export function postEvent(
68 db: Database,
69 who: Who,
70 input: PostEventInput,
71 ): Result<Event> {
72 if (who.role === "anon")
73 return fail("signin_required", "Sign in to post an event.");
74 if (who.role === "user")
75 return fail("forbidden", "Only an app or an admin can post an event.");
76
77 const kind = String(input.kind ?? "");
78 if (kind.length > KIND_MAX || !KIND_RE.test(kind))
79 return fail(
80 "invalid",
81 `kind is dotted lowercase segments (e.g. "order.placed"), at most ${KIND_MAX} characters.`,
82 );
83
84 const subject = String(input.subject ?? "");
85 if (!isPlainText(subject, SUBJECT_MIN, SUBJECT_MAX))
86 return fail(
87 "invalid",
88 `subject is ${SUBJECT_MIN} to ${SUBJECT_MAX} characters, no control characters or newlines.`,
89 );
90
91 const summary = String(input.summary ?? "");
92 if (!isPlainText(summary, SUMMARY_MIN, SUMMARY_MAX))
93 return fail(
94 "invalid",
95 `summary is ${SUMMARY_MIN} to ${SUMMARY_MAX} characters, no control characters or newlines.`,
96 );
97
98 let link: string | null = null;
99 if (input.link !== undefined && input.link !== null && input.link !== "") {
100 const raw = String(input.link);
101 let parsed: URL;
102 try {
103 parsed = new URL(raw);
104 } catch {
105 return fail("invalid", "link must be an absolute http(s) URL.");
106 }
107 if (
108 raw.length > LINK_MAX ||
109 (parsed.protocol !== "http:" && parsed.protocol !== "https:")
110 )
111 return fail(
112 "invalid",
113 `link must be an http(s) URL of at most ${LINK_MAX} characters.`,
114 );
115 link = raw;
116 }
117
118 let source: string;
119 if (who.role === "app") {
120 source = who.id;
121 if (!SOURCE_RE.test(source))
122 return fail("invalid", "source is lowercase letters, digits and hyphens, up to 32 characters.");
123 } else {
124 if (input.source === undefined)
125 return fail("invalid", "An admin names the source of an event.");
126 source = String(input.source);
127 if (!SOURCE_RE.test(source))
128 return fail("invalid", "source is lowercase letters, digits and hyphens, up to 32 characters.");
129 }
130
131 const now = Date.now();
132 let occurred_at: string;
133 if (input.occurred_at !== undefined) {
134 const t = Date.parse(String(input.occurred_at));
135 if (Number.isNaN(t))
136 return fail("invalid", "occurred_at must be an ISO 8601 timestamp.");
137 if (t > now + OCCURRED_AHEAD_MS)
138 return fail("invalid", "occurred_at cannot be more than 5 minutes ahead.");
139 if (t < now - OCCURRED_BEHIND_MS)
140 return fail("invalid", "occurred_at cannot be more than 30 days behind.");
141 occurred_at = new Date(t).toISOString();
142 } else {
143 occurred_at = new Date(now).toISOString();
144 }
145
146 let dedupe: string | null = null;
147 if (
148 input.dedupe !== undefined &&
149 input.dedupe !== null &&
150 input.dedupe !== ""
151 ) {
152 const raw = String(input.dedupe);
153 if (!DEDUPE_RE.test(raw))
154 return fail(
155 "invalid",
156 "dedupe is up to 64 characters of letters, digits, '.', '_' or '-'.",
157 );
158 dedupe = raw;
159 }
160
161 let event: Event;
162 let inserted = true;
163 try {
164 event = db
165 .query<
166 Event,
167 [string, string, string, string, string | null, string, string | null]
168 >(
169 `INSERT INTO event (source, kind, subject, summary, link, occurred_at, dedupe)
170 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *`,
171 )
172 .get(source, kind, subject, summary, link, occurred_at, dedupe)!;
173 } catch (err) {
174 if (dedupe === null) throw err;
175 const existing = db
176 .query<
177 Event,
178 [string, string]
179 >("SELECT * FROM event WHERE source = ?1 AND dedupe = ?2")
180 .get(source, dedupe);
181 if (existing === null) throw err;
182 event = existing;
183 inserted = false;
184 }
185 if (inserted) emit("event.posted", event);
186 return ok(event);
187 }
188
189 export interface ListEventsInput {
190 readonly source?: unknown;
191 readonly kind?: unknown;
192 readonly before?: unknown;
193 readonly limit?: unknown;
194 }
195
196 export function listEvents(
36197 db: Database,
37198 _who: Who,
38 input: { limit?: number } = {},
39 ): Result<Note[]> {
40 const limit = Math.min(
41 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
42 500,
43 );
44 return ok(
45 db
46 .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1")
47 .all(limit),
48 );
49 }
199 input: ListEventsInput = {},
200 ): Result<Event[]> {
201 const filters: string[] = [];
202 const params: (string | number)[] = [];
50203
51 export function addNote(
52 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
204 if (input.source !== undefined) {
205 const source = String(input.source);
206 if (!SOURCE_RE.test(source)) return fail("invalid", "source is invalid.");
207 filters.push("source = ?");
208 params.push(source);
209 }
210
211 if (input.kind !== undefined) {
212 const kind = String(input.kind);
213 if (kind.length > KIND_MAX || !KIND_RE.test(kind))
214 return fail("invalid", "kind is invalid.");
215 filters.push("(kind = ? OR kind LIKE ? || '.%')");
216 params.push(kind, kind);
217 }
218
219 if (input.before !== undefined) {
220 const before = Number(input.before);
221 if (!Number.isInteger(before))
222 return fail("invalid", "before is an event id.");
223 filters.push("id < ?");
224 params.push(before);
225 }
226
227 const limit =
228 input.limit === undefined || !Number.isFinite(Number(input.limit))
229 ? LIMIT_DEFAULT
230 : Math.min(Math.max(Math.trunc(Number(input.limit)), LIMIT_MIN), LIMIT_MAX);
231
232 const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
233 const rows = db
62234 .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);
235 Event,
236 (string | number)[]
237 >(`SELECT * FROM event ${where} ORDER BY id DESC LIMIT ?`)
238 .all(...params, limit);
239 return ok(rows);
69240 }
70241
71 export function deleteNote(
242 export function getEvent(
72243 db: Database,
73 who: Who,
244 _who: Who,
74245 input: { id: number },
75 ): Result<{ id: number }> {
76 if (who.role === "anon")
77 return fail("signin_required", "Sign in to remove a note.");
246 ): Result<Event> {
78247 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")
248 return fail("invalid", "An event id is a whole number.");
249 const event = db
250 .query<Event, [number]>("SELECT * FROM event WHERE id = ?1")
82251 .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 });
252 if (event === null) return fail("not_found", "No such event.");
253 return ok(event);
89254 }
90
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);
src/db.ts +17 −2
@@ -24,13 +24,28 @@ 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 `CREATE TABLE nonce (
27 `CREATE TABLE IF NOT EXISTS nonce (
2828 app_id TEXT NOT NULL,
2929 nonce TEXT NOT NULL,
3030 seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
3131 PRIMARY KEY (app_id, nonce)
3232 ) WITHOUT ROWID`,
33 `CREATE INDEX nonce_seen_at ON nonce (seen_at)`,
33 `CREATE INDEX IF NOT EXISTS nonce_seen_at ON nonce (seen_at)`,
34 `CREATE TABLE IF NOT EXISTS event (
35 id INTEGER PRIMARY KEY,
36 source TEXT NOT NULL,
37 kind TEXT NOT NULL,
38 subject TEXT NOT NULL,
39 summary TEXT NOT NULL,
40 link TEXT,
41 occurred_at TEXT NOT NULL,
42 received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
43 dedupe TEXT
44 )`,
45 `CREATE INDEX IF NOT EXISTS event_source ON event (source)`,
46 `CREATE INDEX IF NOT EXISTS event_kind ON event (kind)`,
47 `CREATE UNIQUE INDEX IF NOT EXISTS event_source_dedupe ON event (source, dedupe) WHERE dedupe IS NOT NULL`,
48 `DROP TABLE IF EXISTS note`,
3449 ];
3550
3651 export function openDb(dir = dataDir): Database {
src/http.ts +1 −45
@@ -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 an event is.
44 */
55
66 import { createHash } from "node:crypto";
@@ -58,39 +58,6 @@ 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 =>
63 new Response(null, {
64 status: 303,
65 headers: {
66 location: "/",
67 ...SECURITY,
68 ...(notice === undefined
69 ? {}
70 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
71 },
72 });
73
74 /** The notice a redirect left, and the header that clears it. */
75 export function readNotice(request: Request): {
76 notice?: string;
77 clear: Record<string, string>;
78 } {
79 const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
80 request.headers.get("cookie") ?? "",
81 )?.[1];
82 if (raw === undefined) return { clear: {} };
83 return {
84 notice: decodeURIComponent(raw),
85 clear: { "set-cookie": cookie("notice", "", 0) },
86 };
87 }
88
89 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
90 // wherever the public address is https.
91 const cookie = (name: string, value: string, maxAge: number): string =>
92 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
93
9461 // A browser on another site can make a person's browser POST here; it cannot
9562 // make that request look same-origin. Browsers say where a request came from
9663 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
@@ -142,17 +109,6 @@ export async function readBody(
142109 return body;
143110 }
144111
145 /** The form fields of a capped body, decoded the way the browser sent them. */
146 export async function readForm(request: Request) {
147 const body = await readBody(request);
148 if (body === undefined) return undefined;
149 return new Request(request.url, {
150 method: "POST",
151 headers: { "content-type": request.headers.get("content-type") ?? "" },
152 body,
153 }).formData();
154 }
155
156112 /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */
157113 export async function readJson(request: Request): Promise<unknown> {
158114 const body = await readBody(request);
src/mcp.ts +33 −15
@@ -7,7 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { addNote, deleteNote, listNotes, type Result } from "./actions.ts";
10 import { getEvent, listEvents, postEvent, type Result } from "./actions.ts";
1111 import { title } from "./env.ts";
1212 import { fields } from "./http.ts";
1313 import type { Who } from "./who.ts";
@@ -25,42 +25,60 @@ interface Tool {
2525
2626 export const tools: readonly Tool[] = [
2727 {
28 name: "list_notes",
29 description: "The notes, newest first.",
28 name: "list_events",
29 description: "The events, newest first, optionally filtered.",
3030 inputSchema: {
3131 type: "object",
3232 properties: {
33 source: { type: "string", description: "Only this source." },
34 kind: {
35 type: "string",
36 description: "Exactly this kind, or a dotted prefix of it.",
37 },
38 before: { type: "integer", description: "Page before this id." },
3339 limit: {
3440 type: "integer",
3541 minimum: 1,
36 maximum: 500,
37 description: "At most this many (100).",
42 maximum: 200,
43 description: "At most this many (50).",
3844 },
3945 },
4046 },
4147 run: (db, who, args) =>
42 listNotes(db, who, { limit: Number(args.limit ?? 100) }),
48 listEvents(db, who, {
49 source: args.source,
50 kind: args.kind,
51 before: args.before,
52 limit: args.limit,
53 }),
4354 },
4455 {
45 name: "add_note",
46 description:
47 "Add a note as the signed-in person. Anonymous callers cannot.",
56 name: "post_event",
57 description: "Post an event as the signed app or an admin.",
4858 inputSchema: {
4959 type: "object",
50 properties: { body: { type: "string", maxLength: 500 } },
51 required: ["body"],
60 properties: {
61 kind: { type: "string", maxLength: 64 },
62 subject: { type: "string", maxLength: 120 },
63 summary: { type: "string", maxLength: 200 },
64 link: { type: "string", maxLength: 512 },
65 source: { type: "string", description: "Admin only." },
66 occurred_at: { type: "string" },
67 dedupe: { type: "string", maxLength: 64 },
68 },
69 required: ["kind", "subject", "summary"],
5270 },
53 run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }),
71 run: (db, who, args) => postEvent(db, who, args),
5472 },
5573 {
56 name: "delete_note",
57 description: "Remove a note by id. Only its author or an admin can.",
74 name: "get_event",
75 description: "One event by id.",
5876 inputSchema: {
5977 type: "object",
6078 properties: { id: { type: "integer" } },
6179 required: ["id"],
6280 },
63 run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }),
81 run: (db, who, args) => getEvent(db, who, { id: Number(args.id) }),
6482 },
6583 ];
6684
src/page.ts +16 −30
@@ -1,10 +1,10 @@
11 /**
22 * 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
3 * `static/live.js` keeps the timeline fresh afterwards. When a second page
44 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
55 */
66
7 import { canDelete, type Note } from "./actions.ts";
7 import type { Event } from "./actions.ts";
88 import { commit, description, env, ssh, title, where } from "./env.ts";
99 import { asset, escape } from "./http.ts";
1010 import type { Who } from "./who.ts";
@@ -39,7 +39,7 @@ export function layout(body: string, who: Who): string {
3939 <main>${body}</main>
4040 <footer class="muted">
4141 ${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>
42 <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p>
4343 </footer>
4444 </div>
4545 </body>
@@ -47,41 +47,27 @@ export function layout(body: string, who: Who): string {
4747 `;
4848 }
4949
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>`;
50 export function home(input: { who: Who; events: readonly Event[] }): string {
51 const { who, events } = input;
6352 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>`;
53 events.length === 0
54 ? `<p class="empty">No events yet. Events posted here stay in ${escape(where)} data.</p>`
55 : `<ul class="list">${events.map(item).join("")}</ul>`;
6756 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>`}`,
57 `<section id="events" data-live="event.posted">${list}</section>`,
7158 who,
7259 );
7360 }
7461
75 const item = (note: Note, who: Who): string => `<li class="note">
62 const item = (event: Event): string => `<li class="event">
7663 <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>
64 <p><b>${escape(event.subject)}</b> — ${escape(event.summary)}</p>
65 <small class="muted">${escape(event.source)} · ${escape(event.kind)} · <time datetime="${escape(event.occurred_at)}">${ago(event.occurred_at)}</time>${
66 event.link === null
67 ? ""
68 : ` · <a href="${escape(event.link)}" rel="noopener noreferrer">link</a>`
69 }</small>
7970 </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 }
8571 </li>`;
8672
8773 const ago = (iso: string): string => {
src/routes.ts +21 −45
@@ -6,20 +6,17 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 addNote,
10 deleteNote,
11 listNotes,
9 getEvent,
10 listEvents,
11 postEvent,
1212 type Failure,
1313 type Result,
1414 } from "./actions.ts";
1515 import {
16 back,
1716 fields,
1817 html,
1918 json,
20 readForm,
2119 readJson,
22 readNotice,
2320 staticFile,
2421 text,
2522 } from "./http.ts";
@@ -58,69 +55,48 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
5855 STATUS[result.error],
5956 );
6057
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
6558 export const routes: readonly Route[] = [
66 // The page. Forms post and come back; the list stays live over /events.
59 // The page: the live timeline, read-only. Posting is a door for apps.
6760 {
6861 method: "GET",
6962 path: "/",
7063 handle: (c) => {
71 const { notice, clear } = readNotice(c.request);
72 const notes = listNotes(c.db, c.who);
73 return html(
74 home({ who: c.who, notes: notes.ok ? notes.value : [], notice }),
75 200,
76 clear,
77 );
64 const listed = listEvents(c.db, c.who);
65 return html(home({ who: c.who, events: listed.ok ? listed.value : [] }));
7866 },
7967 },
80 {
81 method: "POST",
82 path: "/notes",
83 handle: async (c) => {
84 const form = await readForm(c.request);
85 if (form === undefined) return text("too large", 413);
86 return done(
87 addNote(c.db, c.who, { body: String(form.get("body") ?? "") }),
88 );
89 },
90 },
91 {
92 method: "POST",
93 path: "/notes/:id/delete",
94 handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
95 },
9668
9769 // The JSON API: the same actions, the same identity, plain status codes.
9870 {
9971 method: "GET",
100 path: "/api/notes",
72 path: "/api/events",
10173 handle: (c) =>
10274 answer(
103 listNotes(c.db, c.who, {
104 limit: Number(c.url.searchParams.get("limit") ?? 100),
75 listEvents(c.db, c.who, {
76 source: c.url.searchParams.get("source") ?? undefined,
77 kind: c.url.searchParams.get("kind") ?? undefined,
78 before: c.url.searchParams.has("before")
79 ? Number(c.url.searchParams.get("before"))
80 : undefined,
81 limit: c.url.searchParams.has("limit")
82 ? Number(c.url.searchParams.get("limit"))
83 : undefined,
10584 }),
10685 ),
10786 },
10887 {
10988 method: "POST",
110 path: "/api/notes",
89 path: "/api/events",
11190 handle: async (c) => {
11291 const body = await readJson(c.request);
11392 if (body === undefined) return json({ error: "too_large" }, 413);
114 return answer(
115 addNote(c.db, c.who, { body: String(fields(body).body ?? "") }),
116 201,
117 );
93 return answer(postEvent(c.db, c.who, fields(body)), 201);
11894 },
11995 },
12096 {
121 method: "DELETE",
122 path: "/api/notes/:id",
123 handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })),
97 method: "GET",
98 path: "/api/events/:id",
99 handle: (c) => answer(getEvent(c.db, c.who, { id: Number(c.params.id) })),
124100 },
125101
126102 // Live events, and the MCP server (the tools are the actions too).
src/server.test.ts +88 −90
@@ -27,7 +27,6 @@ const as = (
2727 };
2828 };
2929 const alice = as("u1", "alice");
30 const bob = as("u2", "bob");
3130 const root = as("u0", "root", "admin");
3231 const jsonBody = (body: unknown): RequestInit => ({
3332 headers: { "content-type": "application/json" },
@@ -46,79 +45,73 @@ const rpc = (message: unknown, who: Record<string, string> = {}) =>
4645 api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
4746 (r) => r.json() as Promise<any>,
4847 );
48 const event = {
49 kind: "order.placed",
50 subject: "Order #42",
51 summary: "A new order came in.",
52 };
4953
50 test("the page knows who you are and offers the right door", async () => {
54 test("the page knows who you are and shows the timeline", async () => {
5155 const guest = await (await fetch(base + "/")).text();
5256 expect(guest).toContain("<h1>");
5357 expect(guest).toContain("/__forge/signin?to=/");
54 expect(guest).not.toContain('action="/notes"');
5558 const signed = await (await api("/", {}, alice)).text();
5659 expect(signed).toContain("<b>alice</b>");
5760 expect(signed).toContain("/__forge/signout");
58 expect(signed).toContain('action="/notes"');
5961 });
6062
61 test("a form post adds as the person; a guest is sent to sign in", async () => {
62 const form = new FormData();
63 form.set("body", "hello from a form");
64 const refused = await api("/notes", {
65 method: "POST",
66 body: form,
67 redirect: "manual",
68 });
69 expect(refused.status).toBe(303);
70 expect(refused.headers.get("set-cookie")).toContain("notice=");
71 expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
72 expect(await (await fetch(base + "/")).text()).not.toContain(
73 "hello from a form",
63 test("the JSON API: only an admin (or a verified app) posts, as a named source", async () => {
64 const asAnon = await api(
65 "/api/events",
66 { method: "POST", ...jsonBody(event) },
7467 );
75 const posted = await api(
76 "/notes",
77 { method: "POST", body: form, redirect: "manual" },
68 expect(asAnon.status).toBe(401);
69 const asUser = await api(
70 "/api/events",
71 { method: "POST", ...jsonBody(event) },
7872 alice,
7973 );
80 expect(posted.status).toBe(303);
81 expect(posted.headers.get("set-cookie")).toBeNull();
82 const page = await (await api("/", {}, alice)).text();
83 expect(page).toContain("hello from a form");
84 expect(page).toContain("alice ·");
85 });
86
87 test("the JSON API is the same actions with the same identity", async () => {
74 expect(asUser.status).toBe(403);
8875 const created = await api(
89 "/api/notes",
90 { method: "POST", ...jsonBody({ body: "over the api" }) },
91 alice,
76 "/api/events",
77 { method: "POST", ...jsonBody({ ...event, source: "shop" }) },
78 root,
9279 );
9380 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 {
81 const posted = (await created.json()) as { id: number; source: string };
82 expect(posted.source).toBe("shop");
83 const listed = (await (await fetch(base + "/api/events")).json()) as {
9784 id: number;
9885 }[];
99 expect(listed.map((n) => n.id)).toContain(note.id);
100 expect(
101 (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) }))
102 .status,
103 ).toBe(401);
104 expect(
105 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status,
106 ).toBe(403);
107 expect(
108 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
109 ).toBe(200);
110 expect(
111 (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
112 ).toBe(404);
113 expect(
114 (
115 await api(
116 "/api/notes",
117 { method: "POST", ...jsonBody({ body: "" }) },
118 alice,
119 )
120 ).status,
121 ).toBe(422);
86 expect(listed.map((e) => e.id)).toContain(posted.id);
87 const fetched = await api(`/api/events/${posted.id}`);
88 expect(fetched.status).toBe(200);
89 expect((await api(`/api/events/${posted.id + 1_000_000}`)).status).toBe(404);
90 const invalid = await api(
91 "/api/events",
92 { method: "POST", ...jsonBody({ ...event, source: "shop", kind: "" }) },
93 root,
94 );
95 expect(invalid.status).toBe(422);
96 });
97
98 test("kind and source filters work over the API", async () => {
99 await api(
100 "/api/events",
101 { method: "POST", ...jsonBody({ ...event, source: "shop", kind: "order.shipped" }) },
102 root,
103 );
104 const filtered = (await (
105 await fetch(base + "/api/events?source=shop&kind=order")
106 ).json()) as { source: string; kind: string }[];
107 expect(filtered.length).toBeGreaterThan(0);
108 for (const e of filtered) {
109 expect(e.source).toBe("shop");
110 expect(e.kind === "order" || e.kind.startsWith("order.")).toBe(true);
111 }
112 expect((await fetch(base + "/api/events?source=Not Valid!")).status).toBe(
113 422,
114 );
122115 });
123116
124117 test("the MCP server lists the tools and calls them as the caller", async () => {
@@ -131,9 +124,9 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
131124 expect(hello.result.capabilities).toEqual({ tools: {} });
132125 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
133126 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
134 "list_notes",
135 "add_note",
136 "delete_note",
127 "list_events",
128 "post_event",
129 "get_event",
137130 ]);
138131 const call = (name: string, args: unknown, who = {}) =>
139132 rpc(
@@ -145,19 +138,24 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
145138 },
146139 who,
147140 );
148 const refused = await call("add_note", { body: "as nobody" });
141 const refused = await call("post_event", { ...event, source: "shop" });
149142 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,
143 const added = await call(
144 "post_event",
145 { ...event, source: "shop" },
146 root,
156147 );
157 expect(notes.map((n: { id: number }) => n.id)).toContain(note.id);
158 expect(
159 (await call("delete_note", { id: note.id }, root)).result.isError,
160 ).toBeUndefined();
148 expect(added.result.isError).toBeUndefined();
149 const posted = JSON.parse(added.result.content[0].text);
150 expect(posted.source).toBe("shop");
151 const listing = JSON.parse(
152 (await call("list_events", {})).result.content[0].text,
153 );
154 expect(listing.map((e: { id: number }) => e.id)).toContain(posted.id);
155 const found = JSON.parse(
156 (await call("get_event", { id: posted.id })).result.content[0].text,
157 );
158 expect(found.id).toBe(posted.id);
161159 expect(
162160 (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
163161 ).toBe(-32601);
@@ -182,12 +180,12 @@ test("a change is on the event stream, and a late client hears what it missed",
182180 }
183181 };
184182 await api(
185 "/api/notes",
186 { method: "POST", ...jsonBody({ body: "live" }) },
187 alice,
183 "/api/events",
184 { method: "POST", ...jsonBody({ ...event, source: "shop" }) },
185 root,
188186 );
189187 const heard = await next();
190 expect(heard.type).toBe("note.added");
188 expect(heard.type).toBe("event.posted");
191189 await reader.cancel();
192190 const late = await fetch(base + "/events", {
193191 headers: { "last-event-id": String(Number(heard.id) - 1) },
@@ -196,16 +194,16 @@ test("a change is on the event stream, and a late client hears what it missed",
196194 (await late.body!.getReader().read()).value,
197195 );
198196 expect(replay).toContain(`id: ${heard.id}\n`);
199 expect(replay).toContain("note.added");
197 expect(replay).toContain("event.posted");
200198 });
201199
202200 test("writes are rationed per person", async () => {
203 const flood = as("u9", "flood");
201 const flood = as("u9", "flood", "admin");
204202 const answers = await Promise.all(
205203 Array.from({ length: 40 }, () =>
206204 api(
207 "/api/notes",
208 { method: "POST", ...jsonBody({ body: "again" }) },
205 "/api/events",
206 { method: "POST", ...jsonBody({ ...event, source: "flood" }) },
209207 flood,
210208 ),
211209 ),
@@ -222,19 +220,21 @@ test("stands on the security floor", async () => {
222220 );
223221 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
224222 expect(
225 (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
223 (await fetch(base + "/api/events")).headers.get(
224 "content-security-policy",
225 ),
226226 ).toContain("default-src");
227227 });
228228
229229 test("a body is capped by the bytes read, whatever it claims", async () => {
230230 const response = await api(
231 "/notes",
231 "/api/events",
232232 {
233233 method: "POST",
234 headers: { "content-type": "application/x-www-form-urlencoded" },
235 body: "body=" + "x".repeat(1024 * 1024),
234 headers: { "content-type": "application/json" },
235 body: '{"summary":"' + "x".repeat(1024 * 1024) + '"}',
236236 },
237 alice,
237 root,
238238 );
239239 expect(response.status).toBe(413);
240240 });
@@ -246,20 +246,18 @@ test("HEAD sees the app up", async () => {
246246 });
247247
248248 test("refuses a cross-site post", async () => {
249 const form = new FormData();
250 form.set("body", "from another site");
251249 const posted = await api(
252 "/notes",
250 "/api/events",
253251 {
254252 method: "POST",
255 body: form,
256 redirect: "manual",
253 ...jsonBody({ ...event, source: "shop" }),
257254 headers: {
255 "content-type": "application/json",
258256 origin: "https://evil.example",
259257 "sec-fetch-site": "cross-site",
260258 },
261259 },
262 alice,
260 root,
263261 );
264262 expect(posted.status).toBe(403);
265263 });
src/tty.ts +12 −64
@@ -1,17 +1,11 @@
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, r refreshes, q leaves.
5 * Plain ANSI, no dependencies.
66 */
77
8 import {
9 addNote,
10 canDelete,
11 deleteNote,
12 listNotes,
13 type Note,
14 } from "./actions.ts";
8 import { listEvents, type Event } from "./actions.ts";
159 import { openDb } from "./db.ts";
1610 import { ssh, title, where } from "./env.ts";
1711 import { whoAtTty } from "./who.ts";
@@ -26,46 +20,28 @@ const out = (s: string): void => {
2620 };
2721
2822 let cursor = 0;
29 let typing: string | undefined;
30 let said = "";
3123
32 const notes = (): Note[] => {
33 const listed = listNotes(db, me);
24 const events = (): Event[] => {
25 const listed = listEvents(db, me);
3426 return listed.ok ? listed.value : [];
3527 };
3628
3729 function draw(): void {
38 const rows = notes();
30 const rows = events();
3931 const lines = [
4032 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
4133 ssh === undefined ? "" : dim(`connect: ${ssh}`),
4234 "",
4335 ];
44 if (rows.length === 0)
36 if (rows.length === 0) lines.push(dim("No events yet."));
37 rows.forEach((event, i) => {
38 const subject = i === cursor ? bold(event.subject) : event.subject;
4539 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", " ")}`)}`,
40 `${i === cursor ? "▸" : " "} ${subject} — ${event.summary} ${dim(`${event.source} · ${event.kind} · ${event.occurred_at.slice(0, 16).replace("T", " ")}`)}`,
5641 );
5742 });
5843 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(
63 dim(
64 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",
67 ),
68 );
44 lines.push(dim("j/k move · r refresh · q quit"));
6945 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
7046 }
7147
@@ -74,42 +50,14 @@ process.stdin.resume();
7450 process.stdin.setEncoding("utf8");
7551 draw();
7652 process.stdin.on("data", (key: string) => {
77 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;
87 draw();
88 return;
89 }
9053 if (key === "q" || key === "\x03") {
9154 out(`${esc}2J${esc}H`);
9255 process.exit(0);
9356 }
94 const rows = notes();
57 const rows = events();
9558 if (key === "j" || key === "\x1b[B")
9659 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
9760 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 = "";
101 }
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 }
112 }
11361 draw();
11462 });
11563 process.stdout.on("resize", draw);
src/who.ts +2 −1
@@ -15,7 +15,8 @@ import { createHmac, timingSafeEqual } from "node:crypto";
1515
1616 export type Who =
1717 | { role: "anon" }
18 | { role: "user" | "admin"; id: string; name: string };
18 | { role: "user" | "admin"; id: string; name: string }
19 | { role: "app"; id: string; name: string };
1920
2021 export const ANON: Who = { role: "anon" };
2122
static/app.css +4 −61
@@ -9,8 +9,6 @@
99 --accent: #2f5bea;
1010 --accent-fg: #ffffff;
1111 --danger: #c2410c;
12 --toast-bg: #1c1c1a;
13 --toast-fg: #f6f6f3;
1412 --radius: 10px;
1513 --s1: 0.25rem;
1614 --s2: 0.5rem;
@@ -33,8 +31,6 @@
3331 --accent: #7c96ff;
3432 --accent-fg: #0f1a4d;
3533 --danger: #fb923c;
36 --toast-bg: #ecece7;
37 --toast-fg: #131312;
3834 }
3935 }
4036 :root[data-theme="dark"] {
@@ -46,8 +42,6 @@
4642 --accent: #7c96ff;
4743 --accent-fg: #0f1a4d;
4844 --danger: #fb923c;
49 --toast-bg: #ecece7;
50 --toast-fg: #131312;
5145 }
5246
5347 /* The page. */
@@ -110,13 +104,6 @@ code,
110104 }
111105
112106 /* Blocks. */
113 .card {
114 background: var(--surface);
115 border: 1px solid var(--line);
116 border-radius: var(--radius);
117 padding: var(--s4);
118 margin-bottom: var(--s5);
119 }
120107 .row {
121108 display: flex;
122109 align-items: center;
@@ -127,7 +114,7 @@ code,
127114 margin: 0;
128115 padding: 0;
129116 }
130 .note {
117 .event {
131118 display: flex;
132119 justify-content: space-between;
133120 align-items: flex-start;
@@ -135,13 +122,13 @@ code,
135122 padding: var(--s3) 0;
136123 border-top: 1px solid var(--line);
137124 }
138 .note:first-child {
125 .event:first-child {
139126 border-top: 0;
140127 }
141 .note p {
128 .event p {
142129 overflow-wrap: anywhere;
143130 }
144 .note small {
131 .event small {
145132 font-size: var(--text-sm);
146133 }
147134 .empty {
@@ -162,20 +149,6 @@ footer code {
162149 }
163150
164151 /* Controls: at least 44px on a side, keyboard and mouse alike. */
165 .input {
166 flex: 1;
167 min-width: 0;
168 min-height: var(--tap);
169 padding: 0 var(--s3);
170 font: inherit;
171 color: inherit;
172 background: var(--bg);
173 border: 1px solid var(--line);
174 border-radius: var(--radius);
175 }
176 .input::placeholder {
177 color: var(--muted);
178 }
179152 .button {
180153 min-height: var(--tap);
181154 padding: 0 var(--s4);
@@ -217,33 +190,3 @@ footer code {
217190 color: var(--accent);
218191 border-color: currentColor;
219192 }
220
221 /* A word from the last request, shown once, gone in a moment. */
222 .toast {
223 position: fixed;
224 left: 50%;
225 bottom: var(--s5);
226 transform: translateX(-50%);
227 max-width: calc(100% - 2 * var(--s4));
228 padding: var(--s3) var(--s4);
229 color: var(--toast-fg);
230 background: var(--toast-bg);
231 border-radius: 999px;
232 box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
233 animation: toast 6s forwards;
234 }
235 @keyframes toast {
236 0%,
237 85% {
238 opacity: 1;
239 }
240 100% {
241 opacity: 0;
242 visibility: hidden;
243 }
244 }
245 @media (prefers-reduced-motion: reduce) {
246 .toast {
247 animation: none;
248 }
249 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs