Public: anyone can read this; the crew can change it.
The event, not the note: one table, two actions, and GET /api/events #17
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/4-the-event-not-the-note-one-table-two-act → main 3 commits fork
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 | ||
| 29 | 29 | decide nothing themselves. `AGENTS.md` says how to add to it. |
| 30 | 30 | |
| 31 | 31 | | 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 | | |
| 34 | 34 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 35 | 35 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 36 | 36 | | `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. | ||
| 43 | 43 | | `src/env.ts` | what the forge told this process | |
| 44 | 44 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | |
| 45 | 45 | |
| 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 | ||
| 46 | 65 | ## Who you are |
| 47 | 66 | |
| 48 | 67 | 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: | |
| 50 | 69 | |
| 51 | 70 | - **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. | |
| 55 | 78 | |
| 56 | 79 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 57 | 80 | `/__forge/signout`; the forge answers both on the app's own host. |
| 58 | 81 | |
| 59 | 82 | ## Any interface |
| 60 | 83 | |
| 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. | |
| 63 | 85 | |
| 64 | 86 | 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"}' | |
| 68 | 92 | |
| 69 | 93 | |
| 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`: | |
| 72 | 96 | |
| 73 | 97 | sh |
| 74 | 98 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
| @@ -80,14 +104,14 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp | ||
| 80 | 104 | } |
| 81 | 105 | |
| 82 | 106 | |
| 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. | |
| 86 | 109 | |
| 87 | 110 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 88 | 111 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 89 | 112 | 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. | |
| 91 | 115 | |
| 92 | 116 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 93 | 117 | `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"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 5 | import { getEvent, listEvents, postEvent } from "./actions.ts"; | |
| 6 | 6 | import { openDb } from "./db.ts"; |
| 7 | 7 | import type { Who } from "./who.ts"; |
| 8 | 8 | |
| 9 | 9 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| 10 | 10 | const anon: Who = { role: "anon" }; |
| 11 | 11 | 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" }; | |
| 13 | 13 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 14 | 14 | |
| 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); | |
| 28 | 32 | }); |
| 29 | 33 | |
| 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 }); | |
| 33 | 59 | expect(r.ok).toBe(false); |
| 34 | 60 | if (!r.ok) expect(r.error).toBe("invalid"); |
| 35 | 61 | } |
| 36 | 62 | }); |
| 37 | 63 | |
| 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"); | |
| 58 | 186 | }); |
src/actions.ts +216 −55
| @@ -10,13 +10,16 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { emit } from "./live.ts"; |
| 11 | 11 | import type { Who } from "./who.ts"; |
| 12 | 12 | |
| 13 | export interface Note { | |
| 13 | export interface Event { | |
| 14 | 14 | 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; | |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| @@ -30,64 +33,222 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({ | ||
| 30 | 33 | message, |
| 31 | 34 | }); |
| 32 | 35 | |
| 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; | |
| 34 | 50 | |
| 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( | |
| 36 | 197 | db: Database, |
| 37 | 198 | _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)[] = []; | |
| 50 | 203 | |
| 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 | |
| 62 | 234 | .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); | |
| 69 | 240 | } |
| 70 | 241 | |
| 71 | export function deleteNote( | |
| 242 | export function getEvent( | |
| 72 | 243 | db: Database, |
| 73 | who: Who, | |
| 244 | _who: Who, | |
| 74 | 245 | 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> { | |
| 78 | 247 | 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") | |
| 82 | 251 | .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); | |
| 89 | 254 | } |
| 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[] = [ | ||
| 24 | 24 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 25 | 25 | )`, |
| 26 | 26 | `CREATE INDEX note_author ON note (author_id)`, |
| 27 | `CREATE TABLE nonce ( | |
| 27 | `CREATE TABLE IF NOT EXISTS nonce ( | |
| 28 | 28 | app_id TEXT NOT NULL, |
| 29 | 29 | nonce TEXT NOT NULL, |
| 30 | 30 | seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), |
| 31 | 31 | PRIMARY KEY (app_id, nonce) |
| 32 | 32 | ) 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`, | |
| 34 | 49 | ]; |
| 35 | 50 | |
| 36 | 51 | export function openDb(dir = dataDir): Database { |
src/http.ts +1 −45
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The floor every response stands on, and the few things a route needs to |
| 3 | * read a request safely. Nothing in here knows what a note is. | |
| 3 | * read a request safely. Nothing in here knows what an event is. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createHash } from "node:crypto"; |
| @@ -58,39 +58,6 @@ export const text = (body: string, status = 200): Response => | ||
| 58 | 58 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | /** Back to the page, with a word for it to show once (`readNotice`). */ | |
| 62 | export const back = (notice?: string): Response => | |
| 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 | ||
| 94 | 61 | // A browser on another site can make a person's browser POST here; it cannot |
| 95 | 62 | // make that request look same-origin. Browsers say where a request came from |
| 96 | 63 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| @@ -142,17 +109,6 @@ export async function readBody( | ||
| 142 | 109 | return body; |
| 143 | 110 | } |
| 144 | 111 | |
| 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 | ||
| 156 | 112 | /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */ |
| 157 | 113 | export async function readJson(request: Request): Promise<unknown> { |
| 158 | 114 | const body = await readBody(request); |
src/mcp.ts +33 −15
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { title } from "./env.ts"; |
| 12 | 12 | import { fields } from "./http.ts"; |
| 13 | 13 | import type { Who } from "./who.ts"; |
| @@ -25,42 +25,60 @@ interface Tool { | ||
| 25 | 25 | |
| 26 | 26 | export const tools: readonly Tool[] = [ |
| 27 | 27 | { |
| 28 | name: "list_notes", | |
| 29 | description: "The notes, newest first.", | |
| 28 | name: "list_events", | |
| 29 | description: "The events, newest first, optionally filtered.", | |
| 30 | 30 | inputSchema: { |
| 31 | 31 | type: "object", |
| 32 | 32 | 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." }, | |
| 33 | 39 | limit: { |
| 34 | 40 | type: "integer", |
| 35 | 41 | minimum: 1, |
| 36 | maximum: 500, | |
| 37 | description: "At most this many (100).", | |
| 42 | maximum: 200, | |
| 43 | description: "At most this many (50).", | |
| 38 | 44 | }, |
| 39 | 45 | }, |
| 40 | 46 | }, |
| 41 | 47 | 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 | }), | |
| 43 | 54 | }, |
| 44 | 55 | { |
| 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.", | |
| 48 | 58 | inputSchema: { |
| 49 | 59 | 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"], | |
| 52 | 70 | }, |
| 53 | run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), | |
| 71 | run: (db, who, args) => postEvent(db, who, args), | |
| 54 | 72 | }, |
| 55 | 73 | { |
| 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.", | |
| 58 | 76 | inputSchema: { |
| 59 | 77 | type: "object", |
| 60 | 78 | properties: { id: { type: "integer" } }, |
| 61 | 79 | required: ["id"], |
| 62 | 80 | }, |
| 63 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), | |
| 81 | run: (db, who, args) => getEvent(db, who, { id: Number(args.id) }), | |
| 64 | 82 | }, |
| 65 | 83 | ]; |
| 66 | 84 |
src/page.ts +16 −30
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The page, rendered on the server: the first paint is complete HTML, and |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 3 | * `static/live.js` keeps the timeline fresh afterwards. When a second page | |
| 4 | 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 7 | import type { Event } from "./actions.ts"; | |
| 8 | 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 9 | 9 | import { asset, escape } from "./http.ts"; |
| 10 | 10 | import type { Who } from "./who.ts"; |
| @@ -39,7 +39,7 @@ export function layout(body: string, who: Who): string { | ||
| 39 | 39 | <main>${body}</main> |
| 40 | 40 | <footer class="muted"> |
| 41 | 41 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 42 | <p>Also at <a href="/api/notes">/api/notes</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 42 | <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 43 | 43 | </footer> |
| 44 | 44 | </div> |
| 45 | 45 | </body> |
| @@ -47,41 +47,27 @@ export function layout(body: string, who: Who): string { | ||
| 47 | 47 | `; |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | export function home(input: { | |
| 51 | who: Who; | |
| 52 | notes: readonly Note[]; | |
| 53 | notice?: string | undefined; | |
| 54 | }): string { | |
| 55 | const { who, notes, notice } = input; | |
| 56 | const composer = | |
| 57 | who.role === "anon" | |
| 58 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 59 | : `<form class="row" method="post" action="/notes"> | |
| 60 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 61 | <button class="button">Add</button> | |
| 62 | </form>`; | |
| 50 | export function home(input: { who: Who; events: readonly Event[] }): string { | |
| 51 | const { who, events } = input; | |
| 63 | 52 | 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>`; | |
| 67 | 56 | 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>`, | |
| 71 | 58 | who, |
| 72 | 59 | ); |
| 73 | 60 | } |
| 74 | 61 | |
| 75 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 62 | const item = (event: Event): string => `<li class="event"> | |
| 76 | 63 | <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> | |
| 79 | 70 | </div> |
| 80 | ${ | |
| 81 | canDelete(who, note) | |
| 82 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 83 | : "" | |
| 84 | } | |
| 85 | 71 | </li>`; |
| 86 | 72 | |
| 87 | 73 | const ago = (iso: string): string => { |
src/routes.ts +21 −45
| @@ -6,20 +6,17 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | addNote, | |
| 10 | deleteNote, | |
| 11 | listNotes, | |
| 9 | getEvent, | |
| 10 | listEvents, | |
| 11 | postEvent, | |
| 12 | 12 | type Failure, |
| 13 | 13 | type Result, |
| 14 | 14 | } from "./actions.ts"; |
| 15 | 15 | import { |
| 16 | back, | |
| 17 | 16 | fields, |
| 18 | 17 | html, |
| 19 | 18 | json, |
| 20 | readForm, | |
| 21 | 19 | readJson, |
| 22 | readNotice, | |
| 23 | 20 | staticFile, |
| 24 | 21 | text, |
| 25 | 22 | } from "./http.ts"; |
| @@ -58,69 +55,48 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 58 | 55 | STATUS[result.error], |
| 59 | 56 | ); |
| 60 | 57 | |
| 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 | ||
| 65 | 58 | 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. | |
| 67 | 60 | { |
| 68 | 61 | method: "GET", |
| 69 | 62 | path: "/", |
| 70 | 63 | 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 : [] })); | |
| 78 | 66 | }, |
| 79 | 67 | }, |
| 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 | }, | |
| 96 | 68 | |
| 97 | 69 | // The JSON API: the same actions, the same identity, plain status codes. |
| 98 | 70 | { |
| 99 | 71 | method: "GET", |
| 100 | path: "/api/notes", | |
| 72 | path: "/api/events", | |
| 101 | 73 | handle: (c) => |
| 102 | 74 | 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, | |
| 105 | 84 | }), |
| 106 | 85 | ), |
| 107 | 86 | }, |
| 108 | 87 | { |
| 109 | 88 | method: "POST", |
| 110 | path: "/api/notes", | |
| 89 | path: "/api/events", | |
| 111 | 90 | handle: async (c) => { |
| 112 | 91 | const body = await readJson(c.request); |
| 113 | 92 | 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); | |
| 118 | 94 | }, |
| 119 | 95 | }, |
| 120 | 96 | { |
| 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) })), | |
| 124 | 100 | }, |
| 125 | 101 | |
| 126 | 102 | // Live events, and the MCP server (the tools are the actions too). |
src/server.test.ts +88 −90
| @@ -27,7 +27,6 @@ const as = ( | ||
| 27 | 27 | }; |
| 28 | 28 | }; |
| 29 | 29 | const alice = as("u1", "alice"); |
| 30 | const bob = as("u2", "bob"); | |
| 31 | 30 | const root = as("u0", "root", "admin"); |
| 32 | 31 | const jsonBody = (body: unknown): RequestInit => ({ |
| 33 | 32 | headers: { "content-type": "application/json" }, |
| @@ -46,79 +45,73 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 46 | 45 | api("/mcp", { method: "POST", ...jsonBody(message) }, who).then( |
| 47 | 46 | (r) => r.json() as Promise<any>, |
| 48 | 47 | ); |
| 48 | const event = { | |
| 49 | kind: "order.placed", | |
| 50 | subject: "Order #42", | |
| 51 | summary: "A new order came in.", | |
| 52 | }; | |
| 49 | 53 | |
| 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 () => { | |
| 51 | 55 | const guest = await (await fetch(base + "/")).text(); |
| 52 | 56 | expect(guest).toContain("<h1>"); |
| 53 | 57 | expect(guest).toContain("/__forge/signin?to=/"); |
| 54 | expect(guest).not.toContain('action="/notes"'); | |
| 55 | 58 | const signed = await (await api("/", {}, alice)).text(); |
| 56 | 59 | expect(signed).toContain("<b>alice</b>"); |
| 57 | 60 | expect(signed).toContain("/__forge/signout"); |
| 58 | expect(signed).toContain('action="/notes"'); | |
| 59 | 61 | }); |
| 60 | 62 | |
| 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) }, | |
| 74 | 67 | ); |
| 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) }, | |
| 78 | 72 | alice, |
| 79 | 73 | ); |
| 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); | |
| 88 | 75 | 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, | |
| 92 | 79 | ); |
| 93 | 80 | 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 { | |
| 97 | 84 | id: number; |
| 98 | 85 | }[]; |
| 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 | ); | |
| 122 | 115 | }); |
| 123 | 116 | |
| 124 | 117 | 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 () => | ||
| 131 | 124 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 132 | 125 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 133 | 126 | 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", | |
| 137 | 130 | ]); |
| 138 | 131 | const call = (name: string, args: unknown, who = {}) => |
| 139 | 132 | rpc( |
| @@ -145,19 +138,24 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 145 | 138 | }, |
| 146 | 139 | who, |
| 147 | 140 | ); |
| 148 | const refused = await call("add_note", { body: "as nobody" }); | |
| 141 | const refused = await call("post_event", { ...event, source: "shop" }); | |
| 149 | 142 | 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, | |
| 156 | 147 | ); |
| 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); | |
| 161 | 159 | expect( |
| 162 | 160 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| 163 | 161 | ).toBe(-32601); |
| @@ -182,12 +180,12 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 182 | 180 | } |
| 183 | 181 | }; |
| 184 | 182 | 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, | |
| 188 | 186 | ); |
| 189 | 187 | const heard = await next(); |
| 190 | expect(heard.type).toBe("note.added"); | |
| 188 | expect(heard.type).toBe("event.posted"); | |
| 191 | 189 | await reader.cancel(); |
| 192 | 190 | const late = await fetch(base + "/events", { |
| 193 | 191 | 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", | ||
| 196 | 194 | (await late.body!.getReader().read()).value, |
| 197 | 195 | ); |
| 198 | 196 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 199 | expect(replay).toContain("note.added"); | |
| 197 | expect(replay).toContain("event.posted"); | |
| 200 | 198 | }); |
| 201 | 199 | |
| 202 | 200 | test("writes are rationed per person", async () => { |
| 203 | const flood = as("u9", "flood"); | |
| 201 | const flood = as("u9", "flood", "admin"); | |
| 204 | 202 | const answers = await Promise.all( |
| 205 | 203 | Array.from({ length: 40 }, () => |
| 206 | 204 | api( |
| 207 | "/api/notes", | |
| 208 | { method: "POST", ...jsonBody({ body: "again" }) }, | |
| 205 | "/api/events", | |
| 206 | { method: "POST", ...jsonBody({ ...event, source: "flood" }) }, | |
| 209 | 207 | flood, |
| 210 | 208 | ), |
| 211 | 209 | ), |
| @@ -222,19 +220,21 @@ test("stands on the security floor", async () => { | ||
| 222 | 220 | ); |
| 223 | 221 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 224 | 222 | 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 | ), | |
| 226 | 226 | ).toContain("default-src"); |
| 227 | 227 | }); |
| 228 | 228 | |
| 229 | 229 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 230 | 230 | const response = await api( |
| 231 | "/notes", | |
| 231 | "/api/events", | |
| 232 | 232 | { |
| 233 | 233 | 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) + '"}', | |
| 236 | 236 | }, |
| 237 | alice, | |
| 237 | root, | |
| 238 | 238 | ); |
| 239 | 239 | expect(response.status).toBe(413); |
| 240 | 240 | }); |
| @@ -246,20 +246,18 @@ test("HEAD sees the app up", async () => { | ||
| 246 | 246 | }); |
| 247 | 247 | |
| 248 | 248 | test("refuses a cross-site post", async () => { |
| 249 | const form = new FormData(); | |
| 250 | form.set("body", "from another site"); | |
| 251 | 249 | const posted = await api( |
| 252 | "/notes", | |
| 250 | "/api/events", | |
| 253 | 251 | { |
| 254 | 252 | method: "POST", |
| 255 | body: form, | |
| 256 | redirect: "manual", | |
| 253 | ...jsonBody({ ...event, source: "shop" }), | |
| 257 | 254 | headers: { |
| 255 | "content-type": "application/json", | |
| 258 | 256 | origin: "https://evil.example", |
| 259 | 257 | "sec-fetch-site": "cross-site", |
| 260 | 258 | }, |
| 261 | 259 | }, |
| 262 | alice, | |
| 260 | root, | |
| 263 | 261 | ); |
| 264 | 262 | expect(posted.status).toBe(403); |
| 265 | 263 | }); |
src/tty.ts +12 −64
| @@ -1,17 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The app in a terminal. The forge runs this on a pty when someone |
| 3 | 3 | * `ssh`es to the app, over the same data as the page and through the same |
| 4 | * actions, as the account at the keyboard. j/k move, a adds, d removes the | |
| 5 | * note under the cursor, q leaves. Plain ANSI, no dependencies. | |
| 4 | * actions, as the account at the keyboard. j/k move, r refreshes, q leaves. | |
| 5 | * Plain ANSI, no dependencies. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 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"; | |
| 15 | 9 | import { openDb } from "./db.ts"; |
| 16 | 10 | import { ssh, title, where } from "./env.ts"; |
| 17 | 11 | import { whoAtTty } from "./who.ts"; |
| @@ -26,46 +20,28 @@ const out = (s: string): void => { | ||
| 26 | 20 | }; |
| 27 | 21 | |
| 28 | 22 | let cursor = 0; |
| 29 | let typing: string | undefined; | |
| 30 | let said = ""; | |
| 31 | 23 | |
| 32 | const notes = (): Note[] => { | |
| 33 | const listed = listNotes(db, me); | |
| 24 | const events = (): Event[] => { | |
| 25 | const listed = listEvents(db, me); | |
| 34 | 26 | return listed.ok ? listed.value : []; |
| 35 | 27 | }; |
| 36 | 28 | |
| 37 | 29 | function draw(): void { |
| 38 | const rows = notes(); | |
| 30 | const rows = events(); | |
| 39 | 31 | const lines = [ |
| 40 | 32 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, |
| 41 | 33 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| 42 | 34 | "", |
| 43 | 35 | ]; |
| 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; | |
| 45 | 39 | 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", " ")}`)}`, | |
| 56 | 41 | ); |
| 57 | 42 | }); |
| 58 | 43 | 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")); | |
| 69 | 45 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 70 | 46 | } |
| 71 | 47 | |
| @@ -74,42 +50,14 @@ process.stdin.resume(); | ||
| 74 | 50 | process.stdin.setEncoding("utf8"); |
| 75 | 51 | draw(); |
| 76 | 52 | 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 | } | |
| 90 | 53 | if (key === "q" || key === "\x03") { |
| 91 | 54 | out(`${esc}2J${esc}H`); |
| 92 | 55 | process.exit(0); |
| 93 | 56 | } |
| 94 | const rows = notes(); | |
| 57 | const rows = events(); | |
| 95 | 58 | if (key === "j" || key === "\x1b[B") |
| 96 | 59 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); |
| 97 | 60 | 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 | } | |
| 113 | 61 | draw(); |
| 114 | 62 | }); |
| 115 | 63 | process.stdout.on("resize", draw); |
src/who.ts +2 −1
| @@ -15,7 +15,8 @@ import { createHmac, timingSafeEqual } from "node:crypto"; | ||
| 15 | 15 | |
| 16 | 16 | export type Who = |
| 17 | 17 | | { 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 }; | |
| 19 | 20 | |
| 20 | 21 | export const ANON: Who = { role: "anon" }; |
| 21 | 22 |
static/app.css +4 −61
| @@ -9,8 +9,6 @@ | ||
| 9 | 9 | : #2f5bea; |
| 10 | 10 | : #ffffff; |
| 11 | 11 | : #c2410c; |
| 12 | : #1c1c1a; | |
| 13 | : #f6f6f3; | |
| 14 | 12 | : 10px; |
| 15 | 13 | : 0.25rem; |
| 16 | 14 | : 0.5rem; |
| @@ -33,8 +31,6 @@ | ||
| 33 | 31 | : #7c96ff; |
| 34 | 32 | : #0f1a4d; |
| 35 | 33 | : #fb923c; |
| 36 | : #ecece7; | |
| 37 | : #131312; | |
| 38 | 34 | } |
| 39 | 35 | } |
| 40 | 36 | :root[data-theme="dark"] { |
| @@ -46,8 +42,6 @@ | ||
| 46 | 42 | : #7c96ff; |
| 47 | 43 | : #0f1a4d; |
| 48 | 44 | : #fb923c; |
| 49 | : #ecece7; | |
| 50 | : #131312; | |
| 51 | 45 | } |
| 52 | 46 | |
| 53 | 47 | /* The page. */ |
| @@ -110,13 +104,6 @@ code, | ||
| 110 | 104 | } |
| 111 | 105 | |
| 112 | 106 | /* Blocks. */ |
| 113 | .card { | |
| 114 | background: var(); | |
| 115 | border: 1px solid var(); | |
| 116 | border-radius: var(); | |
| 117 | padding: var(); | |
| 118 | margin-bottom: var(); | |
| 119 | } | |
| 120 | 107 | .row { |
| 121 | 108 | display: flex; |
| 122 | 109 | align-items: center; |
| @@ -127,7 +114,7 @@ code, | ||
| 127 | 114 | margin: 0; |
| 128 | 115 | padding: 0; |
| 129 | 116 | } |
| 130 | .note { | |
| 117 | .event { | |
| 131 | 118 | display: flex; |
| 132 | 119 | justify-content: space-between; |
| 133 | 120 | align-items: flex-start; |
| @@ -135,13 +122,13 @@ code, | ||
| 135 | 122 | padding: var() 0; |
| 136 | 123 | border-top: 1px solid var(); |
| 137 | 124 | } |
| 138 | .note:first-child { | |
| 125 | .event:first-child { | |
| 139 | 126 | border-top: 0; |
| 140 | 127 | } |
| 141 | .note p { | |
| 128 | .event p { | |
| 142 | 129 | overflow-wrap: anywhere; |
| 143 | 130 | } |
| 144 | .note small { | |
| 131 | .event small { | |
| 145 | 132 | font-size: var(); |
| 146 | 133 | } |
| 147 | 134 | .empty { |
| @@ -162,20 +149,6 @@ footer code { | ||
| 162 | 149 | } |
| 163 | 150 | |
| 164 | 151 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ |
| 165 | .input { | |
| 166 | flex: 1; | |
| 167 | min-width: 0; | |
| 168 | min-height: var(); | |
| 169 | padding: 0 var(); | |
| 170 | font: inherit; | |
| 171 | color: inherit; | |
| 172 | background: var(); | |
| 173 | border: 1px solid var(); | |
| 174 | border-radius: var(); | |
| 175 | } | |
| 176 | .input::placeholder { | |
| 177 | color: var(); | |
| 178 | } | |
| 179 | 152 | .button { |
| 180 | 153 | min-height: var(); |
| 181 | 154 | padding: 0 var(); |
| @@ -217,33 +190,3 @@ footer code { | ||
| 217 | 190 | color: var(); |
| 218 | 191 | border-color: currentColor; |
| 219 | 192 | } |
| 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(); | |
| 226 | transform: translateX(-50%); | |
| 227 | max-width: calc(100% - 2 * var()); | |
| 228 | padding: var() var(); | |
| 229 | color: var(); | |
| 230 | background: var(); | |
| 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 | } |