Public: anyone can read this; the crew can change it.
A posting key per app: register, rotate, revoke — derived, never stored #22
scout/relay:scout/7-a-posting-key-per-app-register-rotate-re → main 5 commits fork 3 threads · 3 unresolved
README.md +53
| @@ -29,8 +29,14 @@ 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 | <<<<<<< HEAD | |
| 33 | | ---------------------------------- | ------------------------------------------------------------ | | |
| 34 | | `src/actions.ts` | list, add, delete — and who may | | |
| 35 | | `src/apps.ts` | an app's key, derived from the identity key, never stored | | |
| 36 | ======= | |
| 32 | 37 | | ----------------------------------- | ------------------------------------------------------------ | |
| 33 | 38 | | `src/actions.ts` | post, list, get an event — and who may | |
| 39 | >>>>>>> origin/main | |
| 34 | 40 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 35 | 41 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 36 | 42 | | `src/live.ts` | the in-process bus and `GET /events` | |
| @@ -68,6 +74,30 @@ The forge says who is asking on every request it proxies, and the app | ||
| 68 | 74 | verifies the signature (`src/who.ts`); nothing else is trusted. Four tiers: |
| 69 | 75 | |
| 70 | 76 | - **anon** — anyone. Reads. |
| 77 | <<<<<<< HEAD | |
| 78 | - **user** — signed in to the forge and able to see this app. Adds notes, as | |
| 79 | themselves, and removes their own. | |
| 80 | - **admin** — able to write this repository. Removes any note; registers, | |
| 81 | rotates and revokes apps. | |
| 82 | ||
| 83 | An admin's page has an Apps section: register an id and a name, and its key | |
| 84 | is shown once, in a selectable block — copy it then, because it is never | |
| 85 | shown again and no log, error or listing ever carries it. The key is not | |
| 86 | stored: it is `base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, "relay-app:" + | |
| 87 | id + ":" + generation))` (`src/apps.ts`), so rotating an app is only a | |
| 88 | generation bump, which is what makes the old key stop verifying. Revoking | |
| 89 | sets a column a verifying door has to read — a derived key stays | |
| 90 | computable forever, so there is no key to delete. | |
| 91 | ||
| 92 | An app's key is an HMAC under the same `FORGE_APP_IDENTITY_KEY` that signs | |
| 93 | the forge's own identity headers, handed to a third party. Nothing recovers | |
| 94 | that key from an HMAC, and the two can never be confused for one another — | |
| 95 | an identity header is `id|name|role|expires` and an app id's own regex | |
| 96 | admits no `|` — but that separation rides on two regexes agreeing in two | |
| 97 | files, not on the keys being different. A secret of relay's own for | |
| 98 | issuance would keep verifying and issuing apart cleanly; this cut reuses | |
| 99 | what the forge already gives the app instead of asking for a second one. | |
| 100 | ======= | |
| 71 | 101 | - **user** — signed in to the forge and able to see this app. Reads, same as |
| 72 | 102 | anon; posting an event is not for a person. |
| 73 | 103 | - **admin** — able to write this repository. Posts an event under any |
| @@ -75,6 +105,7 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Four tiers: | ||
| 75 | 105 | - **app** — an app on the forge whose signed request this app has verified |
| 76 | 106 | (its own issue); posts under its own `source` only, and cannot claim |
| 77 | 107 | another's. |
| 108 | >>>>>>> origin/main | |
| 78 | 109 | |
| 79 | 110 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 80 | 111 | `/__forge/signout`; the forge answers both on the app's own host. |
| @@ -84,6 +115,21 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Four tiers: | ||
| 84 | 115 | The same actions, the same rules, at every door. |
| 85 | 116 | |
| 86 | 117 | sh |
| 118 | <<<<<<< | |
| 119 | curl https://<app>/api/notes | |
| 120 | curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}' | |
| 121 | curl -X DELETE https://<app>/api/notes/1 | |
| 122 | ||
| 123 | # Apps, admin only — registering and rotating answer with the key once. | |
| 124 | curl https://<app>/api/apps | |
| 125 | curl -X POST https://<app>/api/apps -H 'content-type: application/json' -d '{"id":"torque","name":"Torque"}' | |
| 126 | curl -X POST https://<app>/api/apps/torque/rotate | |
| 127 | curl -X DELETE https://<app>/api/apps/torque | |
| 128 | ||
| 129 | ||
| 130 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 131 | `add_note`, `delete_note`, `list_apps` (admin only, never a key): | |
| 132 | ======= | |
| 87 | 133 | curl https://<app>/api/events |
| 88 | 134 | curl https://<app>/api/events?source=shop&kind=order&limit=20 |
| 89 | 135 | curl https://<app>/api/events/1 |
| @@ -93,6 +139,7 @@ curl -X POST https://<app>/api/events -H 'content-type: application/json' \ | ||
| 93 | 139 | |
| 94 | 140 | As an MCP server, for Claude Code or Claude Desktop — `list_events`, |
| 95 | 141 | `post_event`, `get_event`: |
| 142 | >>>>>>> origin/main | |
| 96 | 143 | |
| 97 | 144 | ```sh |
| 98 | 145 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
| @@ -110,8 +157,14 @@ without a reload. Without JavaScript it is the same page, one reload behind. | ||
| 110 | 157 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 111 | 158 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 112 | 159 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 160 | <<<<<<< HEAD | |
| 161 | a real pty over the same data, as the account at the keyboard; an admin | |
| 162 | presses `p` there to list the registered apps (read-only — registering, | |
| 163 | rotating and revoking are on the page and the API for now). | |
| 164 | ======= | |
| 113 | 165 | a real pty over the same data, as the account at the keyboard: j/k move |
| 114 | 166 | through the timeline, q leaves. |
| 167 | >>>>>>> origin/main | |
| 115 | 168 | |
| 116 | 169 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 117 | 170 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
src/actions.test.ts +160 −1
| @@ -2,11 +2,22 @@ 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 { getEvent, listEvents, postEvent } from "./actions.ts"; | |
| 5 | import { | |
| 6 | getEvent, | |
| 7 | listApps, | |
| 8 | listEvents, | |
| 9 | postEvent, | |
| 10 | registerApp, | |
| 11 | revokeApp, | |
| 12 | rotateAppKey, | |
| 13 | } from "./actions.ts"; | |
| 14 | import { appSecret } from "./apps.ts"; | |
| 6 | 15 | import { openDb } from "./db.ts"; |
| 16 | import { subscribe, type LiveEvent } from "./live.ts"; | |
| 7 | 17 | import type { Who } from "./who.ts"; |
| 8 | 18 | |
| 9 | 19 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| 20 | const KEY = "a-key-only-this-test-holds"; | |
| 10 | 21 | const anon: Who = { role: "anon" }; |
| 11 | 22 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 12 | 23 | const shop: Who = { role: "app", id: "shop", name: "Shop" }; |
| @@ -184,3 +195,151 @@ test("getEvent finds an event by id, 404 when absent", () => { | ||
| 184 | 195 | expect(bad.ok).toBe(false); |
| 185 | 196 | if (!bad.ok) expect(bad.error).toBe("invalid"); |
| 186 | 197 | }); |
| 198 | ||
| 199 | test("only an admin registers, rotates or lists apps", () => { | |
| 200 | for (const who of [anon, alice]) { | |
| 201 | const registered = registerApp( | |
| 202 | db, | |
| 203 | who, | |
| 204 | { id: "torque", name: "Torque" }, | |
| 205 | KEY, | |
| 206 | ); | |
| 207 | expect(registered.ok).toBe(false); | |
| 208 | if (!registered.ok) | |
| 209 | expect(registered.error).toBe( | |
| 210 | who.role === "anon" ? "signin_required" : "forbidden", | |
| 211 | ); | |
| 212 | const listed = listApps(db, who); | |
| 213 | expect(listed.ok).toBe(false); | |
| 214 | if (!listed.ok) | |
| 215 | expect(listed.error).toBe( | |
| 216 | who.role === "anon" ? "signin_required" : "forbidden", | |
| 217 | ); | |
| 218 | } | |
| 219 | }); | |
| 220 | ||
| 221 | test("an app id is lowercase letters, digits and hyphens; a second register is invalid, never a silent rotate", () => { | |
| 222 | for (const id of ["Torque", "-torque", "has space", "x".repeat(33), ""]) { | |
| 223 | const r = registerApp(db, root, { id, name: "Torque" }, KEY); | |
| 224 | expect(r.ok).toBe(false); | |
| 225 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 226 | } | |
| 227 | const first = registerApp(db, root, { id: "torque", name: "Torque" }, KEY); | |
| 228 | expect(first.ok).toBe(true); | |
| 229 | if (!first.ok) return; | |
| 230 | expect(first.value.generation).toBe(1); | |
| 231 | expect(first.value.key).toBe(appSecret("torque", 1, KEY)); | |
| 232 | const again = registerApp( | |
| 233 | db, | |
| 234 | root, | |
| 235 | { id: "torque", name: "Renamed" }, | |
| 236 | KEY, | |
| 237 | ); | |
| 238 | expect(again.ok).toBe(false); | |
| 239 | if (!again.ok) expect(again.error).toBe("invalid"); | |
| 240 | const apps = listApps(db, root); | |
| 241 | expect(apps.ok && apps.value.find((a) => a.id === "torque")?.generation).toBe( | |
| 242 | 1, | |
| 243 | ); | |
| 244 | }); | |
| 245 | ||
| 246 | test("a name is 1 to 60 characters with no control characters", () => { | |
| 247 | for (const name of ["", " ", "x".repeat(61), "bad\nname"]) { | |
| 248 | const r = registerApp(db, root, { id: "widget", name }, KEY); | |
| 249 | expect(r.ok).toBe(false); | |
| 250 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 251 | } | |
| 252 | }); | |
| 253 | ||
| 254 | test("with no identity key, registering and rotating refuse and leave nothing behind", () => { | |
| 255 | const registered = registerApp( | |
| 256 | db, | |
| 257 | root, | |
| 258 | { id: "unfunded", name: "Unfunded" }, | |
| 259 | "", | |
| 260 | ); | |
| 261 | expect(registered.ok).toBe(false); | |
| 262 | if (!registered.ok) expect(registered.error).toBe("unavailable"); | |
| 263 | // No half-made row: the id is free, not stuck at "already registered". | |
| 264 | const beforeRetry = listApps(db, root); | |
| 265 | expect( | |
| 266 | beforeRetry.ok && beforeRetry.value.find((a) => a.id === "unfunded"), | |
| 267 | ).toBeUndefined(); | |
| 268 | expect( | |
| 269 | registerApp(db, root, { id: "unfunded", name: "Unfunded" }, KEY).ok, | |
| 270 | ).toBe(true); | |
| 271 | ||
| 272 | const rotated = rotateAppKey(db, root, { id: "unfunded" }, ""); | |
| 273 | expect(rotated.ok).toBe(false); | |
| 274 | if (!rotated.ok) expect(rotated.error).toBe("unavailable"); | |
| 275 | const summary = listApps(db, root); | |
| 276 | // No half-made rotation: the generation did not move. | |
| 277 | expect( | |
| 278 | summary.ok && summary.value.find((a) => a.id === "unfunded")?.generation, | |
| 279 | ).toBe(1); | |
| 280 | }); | |
| 281 | ||
| 282 | test("rotating bumps the generation and changes the derived secret; a revoked app cannot rotate", () => { | |
| 283 | const registered = registerApp(db, root, { id: "beacon", name: "Beacon" }, KEY); | |
| 284 | if (!registered.ok) throw new Error(registered.message); | |
| 285 | const rotated = rotateAppKey(db, root, { id: "beacon" }, KEY); | |
| 286 | expect(rotated.ok).toBe(true); | |
| 287 | if (!rotated.ok) return; | |
| 288 | expect(rotated.value.generation).toBe(2); | |
| 289 | expect(rotated.value.key).not.toBe(registered.value.key); | |
| 290 | expect(rotated.value.key).toBe(appSecret("beacon", 2, KEY)); | |
| 291 | ||
| 292 | const revoked = revokeApp(db, root, { id: "beacon" }); | |
| 293 | expect(revoked.ok).toBe(true); | |
| 294 | const summary = listApps(db, root); | |
| 295 | expect( | |
| 296 | summary.ok && summary.value.find((a) => a.id === "beacon")?.revoked, | |
| 297 | ).toBe(true); | |
| 298 | // The derivation itself does not know about revocation — only the action does. | |
| 299 | expect(appSecret("beacon", 2, KEY)).toBe(rotated.value.key); | |
| 300 | const rotateRevoked = rotateAppKey(db, root, { id: "beacon" }, KEY); | |
| 301 | expect(rotateRevoked.ok).toBe(false); | |
| 302 | if (!rotateRevoked.ok) expect(rotateRevoked.error).toBe("invalid"); | |
| 303 | ||
| 304 | expect(rotateAppKey(db, root, { id: "no-such-app" }, KEY).ok).toBe(false); | |
| 305 | const bySomeoneElse = rotateAppKey(db, alice, { id: "beacon" }, KEY); | |
| 306 | expect(bySomeoneElse.ok).toBe(false); | |
| 307 | if (!bySomeoneElse.ok) expect(bySomeoneElse.error).toBe("forbidden"); | |
| 308 | }); | |
| 309 | ||
| 310 | test("revoking an already-revoked app is a no-op: one event, not two", () => { | |
| 311 | const registered = registerApp(db, root, { id: "hush", name: "Hush" }, KEY); | |
| 312 | if (!registered.ok) throw new Error(registered.message); | |
| 313 | const seen: LiveEvent[] = []; | |
| 314 | const unsubscribe = subscribe((event) => seen.push(event)); | |
| 315 | expect(revokeApp(db, root, { id: "hush" }).ok).toBe(true); | |
| 316 | expect(revokeApp(db, root, { id: "hush" }).ok).toBe(true); | |
| 317 | unsubscribe(); | |
| 318 | const revokedEvents = seen.filter( | |
| 319 | (e) => e.type === "app.revoked" && (e.data as { id: string }).id === "hush", | |
| 320 | ); | |
| 321 | expect(revokedEvents).toHaveLength(1); | |
| 322 | }); | |
| 323 | ||
| 324 | test("app events are admin-only and never carry the key", () => { | |
| 325 | const seen: LiveEvent[] = []; | |
| 326 | const unsubscribe = subscribe((event) => seen.push(event)); | |
| 327 | const registered = registerApp(db, root, { id: "quiet", name: "Quiet" }, KEY); | |
| 328 | if (!registered.ok) throw new Error(registered.message); | |
| 329 | unsubscribe(); | |
| 330 | const event = seen.find((e) => e.type === "app.registered"); | |
| 331 | expect(event?.audience).toBe("admin"); | |
| 332 | expect(JSON.stringify(event)).not.toContain(registered.value.key); | |
| 333 | ||
| 334 | const apps = listApps(db, root); | |
| 335 | expect(apps.ok).toBe(true); | |
| 336 | if (!apps.ok) return; | |
| 337 | const serialized = JSON.stringify(apps.value); | |
| 338 | expect(serialized).not.toContain(registered.value.key); | |
| 339 | expect(apps.value.find((a) => a.id === "quiet")).toEqual({ | |
| 340 | id: "quiet", | |
| 341 | name: "Quiet", | |
| 342 | generation: 1, | |
| 343 | revoked: false, | |
| 344 | }); | |
| 345 | }); |
src/actions.ts +167 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { appSecret } from "./apps.ts"; | |
| 10 | 11 | import { emit } from "./live.ts"; |
| 11 | 12 | import type { Who } from "./who.ts"; |
| 12 | 13 | |
| @@ -22,7 +23,12 @@ export interface Event { | ||
| 22 | 23 | readonly dedupe: string | null; |
| 23 | 24 | } |
| 24 | 25 | |
| 25 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 26 | export type Failure = | |
| 27 | | "signin_required" | |
| 28 | | "forbidden" | |
| 29 | | "not_found" | |
| 30 | | "invalid" | |
| 31 | | "unavailable"; | |
| 26 | 32 | export type Result<T> = |
| 27 | 33 | | { ok: true; value: T } |
| 28 | 34 | | { ok: false; error: Failure; message: string }; |
| @@ -252,3 +258,163 @@ export function getEvent( | ||
| 252 | 258 | if (event === null) return fail("not_found", "No such event."); |
| 253 | 259 | return ok(event); |
| 254 | 260 | } |
| 261 | ||
| 262 | interface AppRow { | |
| 263 | readonly id: string; | |
| 264 | readonly name: string; | |
| 265 | readonly created_at: string; | |
| 266 | readonly created_by: string; | |
| 267 | readonly generation: number; | |
| 268 | readonly revoked_at: string | null; | |
| 269 | } | |
| 270 | ||
| 271 | /** What `listApps` shows: never the key. */ | |
| 272 | export interface AppSummary { | |
| 273 | readonly id: string; | |
| 274 | readonly name: string; | |
| 275 | readonly generation: number; | |
| 276 | readonly revoked: boolean; | |
| 277 | } | |
| 278 | ||
| 279 | /** What `registerApp` and `rotateAppKey` hand back once, and nothing else ever does. */ | |
| 280 | export interface IssuedKey { | |
| 281 | readonly id: string; | |
| 282 | readonly name: string; | |
| 283 | readonly generation: number; | |
| 284 | readonly key: string; | |
| 285 | } | |
| 286 | ||
| 287 | const APP_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; | |
| 288 | const APP_NAME_MAX = 60; | |
| 289 | const NO_KEY = "No identity key is configured; cannot issue an app key."; | |
| 290 | ||
| 291 | type Gate = | |
| 292 | | { ok: true; id: string } | |
| 293 | | { ok: false; error: Failure; message: string }; | |
| 294 | ||
| 295 | /** The rule every app action shares: only an admin manages apps. */ | |
| 296 | function adminOnly(who: Who): Gate { | |
| 297 | if (who.role === "anon") | |
| 298 | return { | |
| 299 | ok: false, | |
| 300 | error: "signin_required", | |
| 301 | message: "Sign in to manage apps.", | |
| 302 | }; | |
| 303 | if (who.role !== "admin") | |
| 304 | return { | |
| 305 | ok: false, | |
| 306 | error: "forbidden", | |
| 307 | message: "Only an admin may manage apps.", | |
| 308 | }; | |
| 309 | return { ok: true, id: who.id }; | |
| 310 | } | |
| 311 | ||
| 312 | const toSummary = (row: AppRow): AppSummary => ({ | |
| 313 | id: row.id, | |
| 314 | name: row.name, | |
| 315 | generation: row.generation, | |
| 316 | revoked: row.revoked_at !== null, | |
| 317 | }); | |
| 318 | ||
| 319 | export function listApps(db: Database, who: Who): Result<AppSummary[]> { | |
| 320 | const gate = adminOnly(who); | |
| 321 | if (!gate.ok) return fail(gate.error, gate.message); | |
| 322 | return ok( | |
| 323 | db | |
| 324 | .query<AppRow, []>("SELECT * FROM app ORDER BY created_at DESC") | |
| 325 | .all() | |
| 326 | .map(toSummary), | |
| 327 | ); | |
| 328 | } | |
| 329 | ||
| 330 | export function registerApp( | |
| 331 | db: Database, | |
| 332 | who: Who, | |
| 333 | input: { id: string; name: string }, | |
| 334 | identityKey: string, | |
| 335 | ): Result<IssuedKey> { | |
| 336 | const gate = adminOnly(who); | |
| 337 | if (!gate.ok) return fail(gate.error, gate.message); | |
| 338 | if (!APP_ID_RE.test(input.id)) | |
| 339 | return fail( | |
| 340 | "invalid", | |
| 341 | "An app id is 1 to 32 lowercase letters, digits and hyphens, starting with a letter or digit.", | |
| 342 | ); | |
| 343 | const name = input.name.trim(); | |
| 344 | if ( | |
| 345 | name.length === 0 || | |
| 346 | name.length > APP_NAME_MAX || | |
| 347 | /[\x00-\x1f\x7f]/.test(name) | |
| 348 | ) | |
| 349 | return fail( | |
| 350 | "invalid", | |
| 351 | `A name is 1 to ${APP_NAME_MAX} characters, with no control characters.`, | |
| 352 | ); | |
| 353 | const exists = db | |
| 354 | .query<{ id: string }, [string]>("SELECT id FROM app WHERE id = ?1") | |
| 355 | .get(input.id); | |
| 356 | if (exists !== null) | |
| 357 | return fail("invalid", "An app with that id is already registered."); | |
| 358 | // Derive before writing: a refusal to derive must leave no row and no event. | |
| 359 | let key: string; | |
| 360 | try { | |
| 361 | key = appSecret(input.id, 1, identityKey); | |
| 362 | } catch { | |
| 363 | return fail("unavailable", NO_KEY); | |
| 364 | } | |
| 365 | db.query( | |
| 366 | "INSERT INTO app (id, name, created_by) VALUES (?1, ?2, ?3)", | |
| 367 | ).run(input.id, name, gate.id); | |
| 368 | emit("app.registered", { id: input.id, name }, "admin"); | |
| 369 | return ok({ id: input.id, name, generation: 1, key }); | |
| 370 | } | |
| 371 | ||
| 372 | export function rotateAppKey( | |
| 373 | db: Database, | |
| 374 | who: Who, | |
| 375 | input: { id: string }, | |
| 376 | identityKey: string, | |
| 377 | ): Result<IssuedKey> { | |
| 378 | const gate = adminOnly(who); | |
| 379 | if (!gate.ok) return fail(gate.error, gate.message); | |
| 380 | const row = db | |
| 381 | .query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1") | |
| 382 | .get(input.id); | |
| 383 | if (row === null) return fail("not_found", "No such app."); | |
| 384 | if (row.revoked_at !== null) | |
| 385 | return fail("invalid", "A revoked app cannot be rotated."); | |
| 386 | const generation = row.generation + 1; | |
| 387 | // Derive before writing: a refusal to derive must leave the generation unchanged. | |
| 388 | let key: string; | |
| 389 | try { | |
| 390 | key = appSecret(row.id, generation, identityKey); | |
| 391 | } catch { | |
| 392 | return fail("unavailable", NO_KEY); | |
| 393 | } | |
| 394 | db.query("UPDATE app SET generation = ?1 WHERE id = ?2").run( | |
| 395 | generation, | |
| 396 | row.id, | |
| 397 | ); | |
| 398 | emit("app.rotated", { id: row.id, name: row.name }, "admin"); | |
| 399 | return ok({ id: row.id, name: row.name, generation, key }); | |
| 400 | } | |
| 401 | ||
| 402 | export function revokeApp( | |
| 403 | db: Database, | |
| 404 | who: Who, | |
| 405 | input: { id: string }, | |
| 406 | ): Result<{ id: string }> { | |
| 407 | const gate = adminOnly(who); | |
| 408 | if (!gate.ok) return fail(gate.error, gate.message); | |
| 409 | const row = db | |
| 410 | .query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1") | |
| 411 | .get(input.id); | |
| 412 | if (row === null) return fail("not_found", "No such app."); | |
| 413 | if (row.revoked_at === null) { | |
| 414 | db.query( | |
| 415 | "UPDATE app SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1", | |
| 416 | ).run(row.id); | |
| 417 | emit("app.revoked", { id: row.id, name: row.name }, "admin"); | |
| 418 | } | |
| 419 | return ok({ id: row.id }); | |
| 420 | } |
src/apps.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { appSecret } from "./apps.ts"; | |
| 3 | ||
| 4 | const KEY = "a-secret-only-torque-holds"; | |
| 5 | ||
| 6 | // Computed independently (node -e with createHmac), not through this | |
| 7 | // module: HMAC-SHA256("relay-app:torque:1") and ("relay-app:torque:2") | |
| 8 | // under KEY, as base64url. | |
| 9 | const GEN1 = "6oo68-k2a4hcqIpwJ2FgkbxYF-GgdmTx1gVAvboyGDw"; | |
| 10 | const GEN2 = "PSCKcS2oGfvYL3JAR8ft4A9bLwQMrSg8282yo9D0o2o"; | |
| 11 | ||
| 12 | test("a secret matches a hand calculation", () => { | |
| 13 | expect(appSecret("torque", 1, KEY)).toBe(GEN1); | |
| 14 | expect(appSecret("torque", 2, KEY)).toBe(GEN2); | |
| 15 | }); | |
| 16 | ||
| 17 | test("the same id and generation always derive the same secret", () => { | |
| 18 | expect(appSecret("torque", 1, KEY)).toBe(appSecret("torque", 1, KEY)); | |
| 19 | }); | |
| 20 | ||
| 21 | test("a different generation, or a different id, derives a different secret", () => { | |
| 22 | expect(appSecret("torque", 1, KEY)).not.toBe(appSecret("torque", 2, KEY)); | |
| 23 | expect(appSecret("torque", 1, KEY)).not.toBe(appSecret("other", 1, KEY)); | |
| 24 | }); | |
| 25 | ||
| 26 | test("with no identity key, it refuses rather than derive a guessable key", () => { | |
| 27 | expect(() => appSecret("torque", 1, "")).toThrow(); | |
| 28 | }); |
src/apps.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The secret an app holds, computed on demand and stored nowhere: | |
| 3 | * | |
| 4 | * base64url(HMAC-SHA256(identityKey, "relay-app:" + id + ":" + generation)) | |
| 5 | * | |
| 6 | * The same id and generation always derive the same secret, so rotating an | |
| 7 | * app is only ever a generation bump in `app` (`src/actions.ts`) — there is | |
| 8 | * no key to delete, and no key column to leak. `key` is the caller's | |
| 9 | * identity key, threaded through like `who(request, key)` takes one rather | |
| 10 | * than read from the environment itself — the seam that lets a test hand | |
| 11 | * this module a key of its own. With no identity key this refuses outright: | |
| 12 | * a key every visitor could compute by hand is worse than having no apps, | |
| 13 | * and the empty local default is not a secret. | |
| 14 | */ | |
| 15 | ||
| 16 | import { createHmac } from "node:crypto"; | |
| 17 | ||
| 18 | export function appSecret(id: string, generation: number, key: string): string { | |
| 19 | if (key === "") | |
| 20 | throw new Error( | |
| 21 | "apps: no identity key configured; refusing to derive an app key", | |
| 22 | ); | |
| 23 | return createHmac("sha256", key) | |
| 24 | .update(`relay-app:${id}:${generation}`) | |
| 25 | .digest("base64url"); | |
| 26 | } |
src/db.ts +8
| @@ -46,6 +46,14 @@ export const migrations: string[] = [ | ||
| 46 | 46 | `CREATE INDEX IF NOT EXISTS event_kind ON event (kind)`, |
| 47 | 47 | `CREATE UNIQUE INDEX IF NOT EXISTS event_source_dedupe ON event (source, dedupe) WHERE dedupe IS NOT NULL`, |
| 48 | 48 | `DROP TABLE IF EXISTS note`, |
| 49 | `CREATE TABLE app ( | |
| 50 | id TEXT PRIMARY KEY, | |
| 51 | name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60), | |
| 52 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 53 | created_by TEXT NOT NULL, | |
| 54 | generation INTEGER NOT NULL DEFAULT 1, | |
| 55 | revoked_at TEXT | |
| 56 | )`, | |
| 49 | 57 | ]; |
| 50 | 58 | |
| 51 | 59 | export function openDb(dir = dataDir): Database { |
src/http.ts +10 −2
| @@ -46,10 +46,18 @@ export const html = ( | ||
| 46 | 46 | }, |
| 47 | 47 | }); |
| 48 | 48 | |
| 49 | export const json = (data: unknown, status = 200): Response => | |
| 49 | export const json = ( | |
| 50 | data: unknown, | |
| 51 | status = 200, | |
| 52 | headers: Record<string, string> = {}, | |
| 53 | ): Response => | |
| 50 | 54 | new Response(JSON.stringify(data), { |
| 51 | 55 | status, |
| 52 | headers: { "content-type": "application/json; charset=utf-8", ...SECURITY }, | |
| 56 | headers: { | |
| 57 | "content-type": "application/json; charset=utf-8", | |
| 58 | ...SECURITY, | |
| 59 | ...headers, | |
| 60 | }, | |
| 53 | 61 | }); |
| 54 | 62 | |
| 55 | 63 | export const text = (body: string, status = 200): Response => |
src/live.ts +22 −5
| @@ -5,12 +5,20 @@ | ||
| 5 | 5 | * `?since=`) hears what it missed. `static/live.js` turns the stream into |
| 6 | 6 | * DOM events. The bus is this process's: the terminal is another process, |
| 7 | 7 | * and its writes show on the next load rather than live. |
| 8 | * | |
| 9 | * Most of what happens here is public the way the page is: anyone watching | |
| 10 | * `/events` may as well have reloaded `/`. Apps are relay's first private | |
| 11 | * concept, so an event about one carries an audience — `"admin"` — and a | |
| 12 | * viewer who is not one never receives it, on connect or on replay. | |
| 8 | 13 | */ |
| 9 | 14 | |
| 15 | import type { Who } from "./who.ts"; | |
| 16 | ||
| 10 | 17 | export interface LiveEvent { |
| 11 | 18 | readonly id: number; |
| 12 | 19 | readonly type: string; |
| 13 | 20 | readonly data: unknown; |
| 21 | readonly audience?: "admin"; | |
| 14 | 22 | } |
| 15 | 23 | |
| 16 | 24 | const KEEP = 100; |
| @@ -19,8 +27,12 @@ const kept: LiveEvent[] = []; | ||
| 19 | 27 | const listeners = new Set<(event: LiveEvent) => void>(); |
| 20 | 28 | let last = 0; |
| 21 | 29 | |
| 22 | export function emit(type: string, data: unknown): LiveEvent { | |
| 23 | const event: LiveEvent = { id: ++last, type, data }; | |
| 30 | export function emit( | |
| 31 | type: string, | |
| 32 | data: unknown, | |
| 33 | audience?: "admin", | |
| 34 | ): LiveEvent { | |
| 35 | const event: LiveEvent = { id: ++last, type, data, audience }; | |
| 24 | 36 | kept.push(event); |
| 25 | 37 | if (kept.length > KEEP) kept.shift(); |
| 26 | 38 | for (const listen of listeners) listen(event); |
| @@ -32,8 +44,11 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void { | ||
| 32 | 44 | return () => listeners.delete(listen); |
| 33 | 45 | } |
| 34 | 46 | |
| 35 | /** `GET /events`: a stream that replays what `since` missed, then follows. */ | |
| 36 | export function events(request: Request): Response { | |
| 47 | const visible = (event: LiveEvent, who: Who): boolean => | |
| 48 | event.audience === undefined || who.role === "admin"; | |
| 49 | ||
| 50 | /** `GET /events`: a stream that replays what `since` missed, then follows — never what `who` may not see. */ | |
| 51 | export function events(request: Request, who: Who): Response { | |
| 37 | 52 | const since = Number( |
| 38 | 53 | request.headers.get("last-event-id") ?? |
| 39 | 54 | new URL(request.url).searchParams.get("since") ?? |
| @@ -50,10 +65,12 @@ export function events(request: Request): Response { | ||
| 50 | 65 | stop(); |
| 51 | 66 | } |
| 52 | 67 | }; |
| 53 | const send = (event: LiveEvent): void => | |
| 68 | const send = (event: LiveEvent): void => { | |
| 69 | if (!visible(event, who)) return; | |
| 54 | 70 | write( |
| 55 | 71 | `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`, |
| 56 | 72 | ); |
| 73 | }; | |
| 57 | 74 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); |
| 58 | 75 | const unsubscribe = subscribe(send); |
| 59 | 76 | stop = () => { |
src/mcp.ts +14 −1
| @@ -7,7 +7,13 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { getEvent, listEvents, postEvent, type Result } from "./actions.ts"; | |
| 10 | import { | |
| 11 | getEvent, | |
| 12 | listApps, | |
| 13 | listEvents, | |
| 14 | postEvent, | |
| 15 | type Result, | |
| 16 | } from "./actions.ts"; | |
| 11 | 17 | import { title } from "./env.ts"; |
| 12 | 18 | import { fields } from "./http.ts"; |
| 13 | 19 | import type { Who } from "./who.ts"; |
| @@ -80,6 +86,13 @@ export const tools: readonly Tool[] = [ | ||
| 80 | 86 | }, |
| 81 | 87 | run: (db, who, args) => getEvent(db, who, { id: Number(args.id) }), |
| 82 | 88 | }, |
| 89 | { | |
| 90 | name: "list_apps", | |
| 91 | description: | |
| 92 | "The registered apps and their generation, admin only. Never a key.", | |
| 93 | inputSchema: { type: "object", properties: {} }, | |
| 94 | run: (db, who) => listApps(db, who), | |
| 95 | }, | |
| 83 | 96 | ]; |
| 84 | 97 | |
| 85 | 98 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
src/page.ts +50 −4
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Event } from "./actions.ts"; | |
| 7 | import type { AppSummary, Event, IssuedKey } from "./actions.ts"; | |
| 8 | 8 | import { |
| 9 | 9 | commit, |
| 10 | 10 | description, |
| @@ -48,7 +48,7 @@ export function layout(body: string, who: Who): string { | ||
| 48 | 48 | <main>${body}</main> |
| 49 | 49 | <footer class="muted"> |
| 50 | 50 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 51 | <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 51 | <p>Also at <a href="/api/events">/api/events</a>, <a href="/api/apps">/api/apps</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 52 | 52 | </footer> |
| 53 | 53 | </div> |
| 54 | 54 | </body> |
| @@ -62,8 +62,11 @@ export function home(input: { | ||
| 62 | 62 | source: string | null; |
| 63 | 63 | kind: string | null; |
| 64 | 64 | error?: string; |
| 65 | apps: readonly AppSummary[]; | |
| 66 | issued?: IssuedKey | undefined; | |
| 67 | notice?: string | undefined; | |
| 65 | 68 | }): string { |
| 66 | const { who, events, source, kind, error } = input; | |
| 69 | const { who, events, source, kind, error, apps, issued, notice } = input; | |
| 67 | 70 | const filtered = source !== null || kind !== null; |
| 68 | 71 | const body = |
| 69 | 72 | events.length === 0 ? empty(filtered) : days(events, source, kind); |
| @@ -71,11 +74,54 @@ export function home(input: { | ||
| 71 | 74 | `<section id="events" data-live="event.posted"> |
| 72 | 75 | ${toolbar(source, kind, error)} |
| 73 | 76 | ${body} |
| 74 | </section>`, | |
| 77 | </section> | |
| 78 | ${who.role === "admin" ? appsSection(apps, issued) : ""} | |
| 79 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, | |
| 75 | 80 | who, |
| 76 | 81 | ); |
| 77 | 82 | } |
| 78 | 83 | |
| 84 | /** The admin-only section: register, rotate, revoke. Nothing here shows twice — a key `issued` just now, and never again. */ | |
| 85 | function appsSection(apps: readonly AppSummary[], issued?: IssuedKey): string { | |
| 86 | const issuedBlock = | |
| 87 | issued === undefined | |
| 88 | ? "" | |
| 89 | : `<div class="card issued-key" role="status"> | |
| 90 | <p>Key for <b>${escape(issued.id)}</b>, generation ${issued.generation}:</p> | |
| 91 | <pre class="key"><code>${escape(issued.key)}</code></pre> | |
| 92 | <p class="muted">This is shown once. Copy it now — it will not be shown again.</p> | |
| 93 | </div>`; | |
| 94 | const list = | |
| 95 | apps.length === 0 | |
| 96 | ? `<p class="empty">No apps registered yet.</p>` | |
| 97 | : `<ul class="list">${apps.map(appRow).join("")}</ul>`; | |
| 98 | return `<section class="card"> | |
| 99 | <h2>Apps</h2> | |
| 100 | ${issuedBlock} | |
| 101 | <form class="row" method="post" action="/apps"> | |
| 102 | <input class="input" name="id" placeholder="app-id" aria-label="App id" required maxlength="32" pattern="[a-z0-9][a-z0-9-]{0,31}" autocomplete="off"> | |
| 103 | <input class="input" name="name" placeholder="Name" aria-label="App name" required maxlength="60" autocomplete="off"> | |
| 104 | <button class="button">Register</button> | |
| 105 | </form> | |
| 106 | <div id="apps" data-live="app.registered app.rotated app.revoked">${list}</div> | |
| 107 | </section>`; | |
| 108 | } | |
| 109 | ||
| 110 | const appRow = (app: AppSummary): string => `<li class="item"> | |
| 111 | <div> | |
| 112 | <p>${escape(app.id)} <span class="muted">— ${escape(app.name)}</span>${app.revoked ? ` <span class="muted">· revoked</span>` : ""}</p> | |
| 113 | <small class="muted">generation ${app.generation}</small> | |
| 114 | </div> | |
| 115 | <div class="row"> | |
| 116 | <form method="post" action="/apps/${escape(app.id)}/rotate"><button class="button button-quiet" aria-label="Rotate the key for ${escape(app.id)}">Rotate</button></form> | |
| 117 | ${ | |
| 118 | app.revoked | |
| 119 | ? "" | |
| 120 | : `<form method="post" action="/apps/${escape(app.id)}/revoke"><button class="button button-quiet" aria-label="Revoke ${escape(app.id)}">Revoke</button></form>` | |
| 121 | } | |
| 122 | </div> | |
| 123 | </li>`; | |
| 124 | ||
| 79 | 125 | /** A link that carries the filter forward: dropping a field clears it. */ |
| 80 | 126 | const filterHref = (source: string | null, kind: string | null): string => { |
| 81 | 127 | const params = new URLSearchParams(); |
src/routes.ts +128 −4
| @@ -6,10 +6,21 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | <<<<<<< HEAD | |
| 10 | addNote, | |
| 11 | deleteNote, | |
| 12 | listApps, | |
| 13 | listNotes, | |
| 14 | registerApp, | |
| 15 | revokeApp, | |
| 16 | rotateAppKey, | |
| 17 | ======= | |
| 9 | 18 | getEvent, |
| 10 | 19 | listEvents, |
| 11 | 20 | postEvent, |
| 21 | >>>>>>> origin/main | |
| 12 | 22 | type Failure, |
| 23 | type IssuedKey, | |
| 13 | 24 | type Result, |
| 14 | 25 | } from "./actions.ts"; |
| 15 | 26 | import { |
| @@ -32,6 +43,7 @@ export interface Context { | ||
| 32 | 43 | readonly db: Database; |
| 33 | 44 | readonly who: Who; |
| 34 | 45 | readonly params: Record<string, string>; |
| 46 | readonly identityKey: string; | |
| 35 | 47 | } |
| 36 | 48 | |
| 37 | 49 | export interface Route { |
| @@ -45,12 +57,33 @@ const STATUS: Record<Failure, number> = { | ||
| 45 | 57 | forbidden: 403, |
| 46 | 58 | not_found: 404, |
| 47 | 59 | invalid: 422, |
| 60 | unavailable: 503, | |
| 48 | 61 | }; |
| 49 | 62 | |
| 50 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ | |
| 51 | const answer = <T>(result: Result<T>, status = 200): Response => | |
| 63 | /** The page after an app action: the same page, freshly rendered — never a redirect, so a key it just issued is never resent. A browser does not cache a POST response, but the one response here that can carry a key says so anyway. */ | |
| 64 | const appsPage = (c: Context, issued?: IssuedKey): Response => { | |
| 65 | const notes = listNotes(c.db, c.who); | |
| 66 | const apps = listApps(c.db, c.who); | |
| 67 | return html( | |
| 68 | home({ | |
| 69 | who: c.who, | |
| 70 | notes: notes.ok ? notes.value : [], | |
| 71 | apps: apps.ok ? apps.value : [], | |
| 72 | issued, | |
| 73 | }), | |
| 74 | issued === undefined ? 200 : 201, | |
| 75 | issued === undefined ? {} : { "cache-control": "no-store" }, | |
| 76 | ); | |
| 77 | }; | |
| 78 | ||
| 79 | /** A result as JSON: the value (with any extra headers, for a result that may carry a key), or `{ error, message }` with the matching status. */ | |
| 80 | const answer = <T>( | |
| 81 | result: Result<T>, | |
| 82 | status = 200, | |
| 83 | headers: Record<string, string> = {}, | |
| 84 | ): Response => | |
| 52 | 85 | result.ok |
| 53 | ? json(result.value, status) | |
| 86 | ? json(result.value, status, headers) | |
| 54 | 87 | : json( |
| 55 | 88 | { error: result.error, message: result.message }, |
| 56 | 89 | STATUS[result.error], |
| @@ -62,6 +95,20 @@ export const routes: readonly Route[] = [ | ||
| 62 | 95 | method: "GET", |
| 63 | 96 | path: "/", |
| 64 | 97 | handle: (c) => { |
| 98 | <<<<<<< HEAD | |
| 99 | const { notice, clear } = readNotice(c.request); | |
| 100 | const notes = listNotes(c.db, c.who); | |
| 101 | const apps = listApps(c.db, c.who); | |
| 102 | return html( | |
| 103 | home({ | |
| 104 | who: c.who, | |
| 105 | notes: notes.ok ? notes.value : [], | |
| 106 | apps: apps.ok ? apps.value : [], | |
| 107 | notice, | |
| 108 | }), | |
| 109 | 200, | |
| 110 | clear, | |
| 111 | ======= | |
| 65 | 112 | const source = c.url.searchParams.get("source"); |
| 66 | 113 | const kind = c.url.searchParams.get("kind"); |
| 67 | 114 | const listed = listEvents(c.db, c.who, { |
| @@ -76,10 +123,48 @@ export const routes: readonly Route[] = [ | ||
| 76 | 123 | kind, |
| 77 | 124 | error: listed.ok ? undefined : listed.message, |
| 78 | 125 | }), |
| 126 | >>>>>>> origin/main | |
| 79 | 127 | ); |
| 80 | 128 | }, |
| 81 | 129 | }, |
| 82 | 130 | |
| 131 | // Apps: registering and rotating hand back a key once, so a success | |
| 132 | // renders the page directly instead of a redirect — nowhere for the key | |
| 133 | // to sit between the response that carries it and the page that doesn't. | |
| 134 | { | |
| 135 | method: "POST", | |
| 136 | path: "/apps", | |
| 137 | handle: async (c) => { | |
| 138 | const form = await readForm(c.request); | |
| 139 | if (form === undefined) return text("too large", 413); | |
| 140 | const result = registerApp( | |
| 141 | c.db, | |
| 142 | c.who, | |
| 143 | { id: String(form.get("id") ?? ""), name: String(form.get("name") ?? "") }, | |
| 144 | c.identityKey, | |
| 145 | ); | |
| 146 | return result.ok ? appsPage(c, result.value) : back(result.message); | |
| 147 | }, | |
| 148 | }, | |
| 149 | { | |
| 150 | method: "POST", | |
| 151 | path: "/apps/:id/rotate", | |
| 152 | handle: (c) => { | |
| 153 | const result = rotateAppKey( | |
| 154 | c.db, | |
| 155 | c.who, | |
| 156 | { id: c.params.id ?? "" }, | |
| 157 | c.identityKey, | |
| 158 | ); | |
| 159 | return result.ok ? appsPage(c, result.value) : back(result.message); | |
| 160 | }, | |
| 161 | }, | |
| 162 | { | |
| 163 | method: "POST", | |
| 164 | path: "/apps/:id/revoke", | |
| 165 | handle: (c) => done(revokeApp(c.db, c.who, { id: c.params.id ?? "" })), | |
| 166 | }, | |
| 167 | ||
| 83 | 168 | // The JSON API: the same actions, the same identity, plain status codes. |
| 84 | 169 | { |
| 85 | 170 | method: "GET", |
| @@ -112,9 +197,48 @@ export const routes: readonly Route[] = [ | ||
| 112 | 197 | path: "/api/events/:id", |
| 113 | 198 | handle: (c) => answer(getEvent(c.db, c.who, { id: Number(c.params.id) })), |
| 114 | 199 | }, |
| 200 | { | |
| 201 | method: "GET", | |
| 202 | path: "/api/apps", | |
| 203 | handle: (c) => answer(listApps(c.db, c.who)), | |
| 204 | }, | |
| 205 | { | |
| 206 | method: "POST", | |
| 207 | path: "/api/apps", | |
| 208 | handle: async (c) => { | |
| 209 | const body = await readJson(c.request); | |
| 210 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 211 | const f = fields(body); | |
| 212 | return answer( | |
| 213 | registerApp( | |
| 214 | c.db, | |
outdated
Changes requested. Read every changed line at 9d122bc, ran 1. High — the admin-only app listing is broadcast to anyone, on this line. 2. High — register and rotate write the row and fire the event before they derive the key, so the refusal The path that gets there deserves the same fix: 3. Medium — both doors. The page gained register, rotate and revoke; Smaller, while you are in here
For whoever merges: the branch is cut from 0700826 and conflicts with current What is good and should stay: the preview answers The one thing first: filter (Housekeeping: two comments reading |
||
| 215 | c.who, | |
| 216 | { id: String(f.id ?? ""), name: String(f.name ?? "") }, | |
| 217 | c.identityKey, | |
| 218 | ), | |
| 219 | 201, | |
| 220 | { "cache-control": "no-store" }, | |
| 221 | ); | |
| 222 | }, | |
| 223 | }, | |
| 224 | { | |
| 225 | method: "POST", | |
| 226 | path: "/api/apps/:id/rotate", | |
| 227 | handle: (c) => | |
| 228 | answer( | |
| 229 | rotateAppKey(c.db, c.who, { id: c.params.id ?? "" }, c.identityKey), | |
| 230 | 200, | |
| 231 | { "cache-control": "no-store" }, | |
| 232 | ), | |
| 233 | }, | |
| 234 | { | |
| 235 | method: "DELETE", | |
| 236 | path: "/api/apps/:id", | |
| 237 | handle: (c) => answer(revokeApp(c.db, c.who, { id: c.params.id ?? "" })), | |
| 238 | }, | |
| 115 | 239 | |
| 116 | 240 | // Live events, and the MCP server (the tools are the actions too). |
| 117 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, | |
| 241 | { method: "GET", path: "/events", handle: (c) => events(c.request, c.who) }, | |
| 118 | 242 | { |
| 119 | 243 | method: "POST", |
| 120 | 244 | path: "/mcp", |
src/server.test.ts +165
| @@ -71,7 +71,67 @@ test("the JSON API: only an admin (or a verified app) posts, as a named source", | ||
| 71 | 71 | { method: "POST", ...jsonBody(event) }, |
| 72 | 72 | alice, |
| 73 | 73 | ); |
| 74 | <<<<<<< HEAD | |
| 75 | expect(posted.status).toBe(303); | |
| 76 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 77 | const page = await (await api("/", {}, alice)).text(); | |
| 78 | expect(page).toContain("hello from a form"); | |
| 79 | expect(page).toContain("alice ·"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a non-admin sees no apps section; an admin registers, rotates and revokes through the page", async () => { | |
| 83 | const guestPage = await (await api("/", {}, alice)).text(); | |
| 84 | expect(guestPage).not.toContain("<h2>Apps</h2>"); | |
| 85 | expect(guestPage).not.toContain('action="/apps"'); | |
| 86 | ||
| 87 | const rootPage = await (await api("/", {}, root)).text(); | |
| 88 | expect(rootPage).toContain("<h2>Apps</h2>"); | |
| 89 | ||
| 90 | const form = new FormData(); | |
| 91 | form.set("id", "torque"); | |
| 92 | form.set("name", "Torque"); | |
| 93 | const registered = await api( | |
| 94 | "/apps", | |
| 95 | { method: "POST", body: form }, | |
| 96 | root, | |
| 97 | ); | |
| 98 | expect(registered.status).toBe(201); | |
| 99 | expect(registered.headers.get("cache-control")).toBe("no-store"); | |
| 100 | const body = await registered.text(); | |
| 101 | expect(body).toContain("will not be shown again"); | |
| 102 | const key = /<code>([^<]+)<\/code>/.exec(body)?.[1]; | |
| 103 | expect(key).toBeDefined(); | |
| 104 | ||
| 105 | // The key is never shown again, not even to the admin who just made it. | |
| 106 | const again = await (await api("/", {}, root)).text(); | |
| 107 | expect(again).not.toContain(key!); | |
| 108 | expect(again).toContain("torque"); | |
| 109 | expect(again).toContain("generation 1"); | |
| 110 | ||
| 111 | const rotated = await api( | |
| 112 | "/apps/torque/rotate", | |
| 113 | { method: "POST" }, | |
| 114 | root, | |
| 115 | ); | |
| 116 | expect(rotated.status).toBe(201); | |
| 117 | const rotatedKey = /<code>([^<]+)<\/code>/.exec(await rotated.text())?.[1]; | |
| 118 | expect(rotatedKey).toBeDefined(); | |
| 119 | expect(rotatedKey).not.toBe(key); | |
| 120 | ||
| 121 | const revoked = await api( | |
| 122 | "/apps/torque/revoke", | |
| 123 | { method: "POST", redirect: "manual" }, | |
| 124 | root, | |
| 125 | ); | |
| 126 | expect(revoked.status).toBe(303); | |
| 127 | const afterRevoke = await (await api("/", {}, root)).text(); | |
| 128 | expect(afterRevoke).toContain("revoked"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("the JSON API is the same actions with the same identity", async () => { | |
| 132 | ======= | |
| 74 | 133 | expect(asUser.status).toBe(403); |
| 134 | >>>>>>> origin/main | |
| 75 | 135 | const created = await api( |
| 76 | 136 | "/api/events", |
| 77 | 137 | { method: "POST", ...jsonBody({ ...event, source: "shop" }) }, |
| @@ -173,6 +233,92 @@ test("kind and source filters work over the API", async () => { | ||
| 173 | 233 | ); |
| 174 | 234 | }); |
| 175 | 235 | |
| 236 | test("the app API is admin only, refuses a duplicate id, and never returns a key from listApps", async () => { | |
| 237 | expect((await api("/api/apps")).status).toBe(401); | |
| 238 | expect((await api("/api/apps", {}, bob)).status).toBe(403); | |
| 239 | ||
| 240 | const create = await api( | |
| 241 | "/api/apps", | |
| 242 | { method: "POST", ...jsonBody({ id: "beacon", name: "Beacon" }) }, | |
| 243 | root, | |
| 244 | ); | |
| 245 | expect(create.status).toBe(201); | |
| 246 | expect(create.headers.get("cache-control")).toBe("no-store"); | |
| 247 | const issued = (await create.json()) as { key: string; generation: number }; | |
| 248 | expect(issued.generation).toBe(1); | |
| 249 | ||
| 250 | const dup = await api( | |
| 251 | "/api/apps", | |
| 252 | { method: "POST", ...jsonBody({ id: "beacon", name: "Again" }) }, | |
| 253 | root, | |
| 254 | ); | |
| 255 | expect(dup.status).toBe(422); | |
| 256 | ||
| 257 | const listed = (await (await api("/api/apps", {}, root)).json()) as Record< | |
| 258 | string, | |
| 259 | unknown | |
| 260 | >[]; | |
| 261 | expect(JSON.stringify(listed)).not.toContain(issued.key); | |
| 262 | expect(listed.find((a) => a.id === "beacon")).toEqual({ | |
| 263 | id: "beacon", | |
| 264 | name: "Beacon", | |
| 265 | generation: 1, | |
| 266 | revoked: false, | |
| 267 | }); | |
| 268 | ||
| 269 | const rotated = await api( | |
| 270 | "/api/apps/beacon/rotate", | |
| 271 | { method: "POST" }, | |
| 272 | root, | |
| 273 | ); | |
| 274 | expect(rotated.status).toBe(200); | |
| 275 | const rotatedKey = (await rotated.json()) as { key: string }; | |
| 276 | expect(rotatedKey.key).not.toBe(issued.key); | |
| 277 | ||
| 278 | expect((await api("/api/apps/beacon", { method: "DELETE" }, bob)).status).toBe( | |
| 279 | 403, | |
| 280 | ); | |
| 281 | expect( | |
| 282 | (await api("/api/apps/beacon", { method: "DELETE" }, root)).status, | |
| 283 | ).toBe(200); | |
| 284 | expect( | |
| 285 | (await api("/api/apps/no-such-app", { method: "DELETE" }, root)).status, | |
| 286 | ).toBe(404); | |
| 287 | }); | |
| 288 | ||
| 289 | test("app events are admin-only on the wire: an anonymous stream hears notes, never apps", async () => { | |
| 290 | const stream = await fetch(base + "/events"); | |
| 291 | const reader = stream.body!.getReader(); | |
| 292 | const frames: string[] = []; | |
| 293 | let buffer = ""; | |
| 294 | const readUntil = async (match: RegExp): Promise<void> => { | |
| 295 | for (;;) { | |
| 296 | const { value } = await reader.read(); | |
| 297 | buffer += new TextDecoder().decode(value); | |
| 298 | const found = match.exec(buffer); | |
| 299 | if (found) { | |
| 300 | frames.push(found[0]); | |
| 301 | buffer = buffer.slice(found.index + found[0].length); | |
| 302 | return; | |
| 303 | } | |
| 304 | } | |
| 305 | }; | |
| 306 | await api( | |
| 307 | "/api/apps", | |
| 308 | { method: "POST", ...jsonBody({ id: "watched", name: "Watched" }) }, | |
| 309 | root, | |
| 310 | ); | |
| 311 | await api( | |
| 312 | "/api/notes", | |
| 313 | { method: "POST", ...jsonBody({ body: "audience check" }) }, | |
| 314 | alice, | |
| 315 | ); | |
| 316 | await readUntil(/id: \d+\ndata: (.*)\n\n/); | |
| 317 | await reader.cancel(); | |
| 318 | expect(frames.some((f) => f.includes("app.registered"))).toBe(false); | |
| 319 | expect(frames.some((f) => f.includes("note.added"))).toBe(true); | |
| 320 | }); | |
| 321 | ||
| 176 | 322 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| 177 | 323 | const hello = await rpc({ |
| 178 | 324 | jsonrpc: "2.0", |
| @@ -183,9 +329,16 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 183 | 329 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 184 | 330 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 185 | 331 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 332 | <<<<<<< HEAD | |
| 333 | "list_notes", | |
| 334 | "add_note", | |
| 335 | "delete_note", | |
| 336 | "list_apps", | |
| 337 | ======= | |
| 186 | 338 | "list_events", |
| 187 | 339 | "post_event", |
| 188 | 340 | "get_event", |
| 341 | >>>>>>> origin/main | |
| 189 | 342 | ]); |
| 190 | 343 | const call = (name: string, args: unknown, who = {}) => |
| 191 | 344 | rpc( |
| @@ -204,6 +357,17 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 204 | 357 | { ...event, source: "shop" }, |
| 205 | 358 | root, |
| 206 | 359 | ); |
| 360 | <<<<<<< HEAD | |
| 361 | expect(notes.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 362 | expect( | |
| 363 | (await call("delete_note", { id: note.id }, root)).result.isError, | |
| 364 | ).toBeUndefined(); | |
| 365 | const appsRefused = await call("list_apps", {}, alice); | |
| 366 | expect(appsRefused.result.isError).toBe(true); | |
| 367 | const appsListed = await call("list_apps", {}, root); | |
| 368 | expect(appsListed.result.isError).toBeUndefined(); | |
| 369 | expect(appsListed.result.content[0].text).not.toContain('"key"'); | |
| 370 | ======= | |
| 207 | 371 | expect(added.result.isError).toBeUndefined(); |
| 208 | 372 | const posted = JSON.parse(added.result.content[0].text); |
| 209 | 373 | expect(posted.source).toBe("shop"); |
| @@ -215,6 +379,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 215 | 379 | (await call("get_event", { id: posted.id })).result.content[0].text, |
| 216 | 380 | ); |
| 217 | 381 | expect(found.id).toBe(posted.id); |
| 382 | >>>>>>> origin/main | |
| 218 | 383 | expect( |
| 219 | 384 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| 220 | 385 | ).toBe(-32601); |
src/server.ts +1
| @@ -52,6 +52,7 @@ export function serve(options: ServeOptions) { | ||
| 52 | 52 | db, |
| 53 | 53 | who: me, |
| 54 | 54 | params: found.params, |
| 55 | identityKey: options.identityKey, | |
| 55 | 56 | }); |
| 56 | 57 | // HEAD is GET without the body: a health check sees the app up. |
| 57 | 58 | return request.method === "HEAD" |
src/tty.ts +60
| @@ -5,7 +5,18 @@ | ||
| 5 | 5 | * Plain ANSI, no dependencies. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | <<<<<<< HEAD | |
| 9 | import { | |
| 10 | addNote, | |
| 11 | canDelete, | |
| 12 | deleteNote, | |
| 13 | listApps, | |
| 14 | listNotes, | |
| 15 | type Note, | |
| 16 | } from "./actions.ts"; | |
| 17 | ======= | |
| 8 | 18 | import { listEvents, type Event } from "./actions.ts"; |
| 19 | >>>>>>> origin/main | |
| 9 | 20 | import { openDb } from "./db.ts"; |
| 10 | 21 | import { ssh, title, where } from "./env.ts"; |
| 11 | 22 | import { whoAtTty } from "./who.ts"; |
| @@ -41,7 +52,22 @@ function draw(): void { | ||
| 41 | 52 | ); |
| 42 | 53 | }); |
| 43 | 54 | lines.push(""); |
| 55 | <<<<<<< HEAD | |
| 56 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 57 | else if (said !== "") lines.push(said); | |
| 58 | else | |
| 59 | lines.push( | |
| 60 | dim( | |
| 61 | me.role === "anon" | |
| 62 | ? "j/k move · q quit · sign in with a key the forge knows to add" | |
| 63 | : me.role === "admin" | |
| 64 | ? "j/k move · a add · d delete · p apps · q quit" | |
| 65 | : "j/k move · a add · d delete · q quit", | |
| 66 | ), | |
| 67 | ); | |
| 68 | ======= | |
| 44 | 69 | lines.push(dim("j/k move · r refresh · q quit")); |
| 70 | >>>>>>> origin/main | |
| 45 | 71 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 46 | 72 | } |
| 47 | 73 | |
| @@ -58,6 +84,40 @@ process.stdin.on("data", (key: string) => { | ||
| 58 | 84 | if (key === "j" || key === "\x1b[B") |
| 59 | 85 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); |
| 60 | 86 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); |
| 87 | <<<<<<< HEAD | |
| 88 | if (key === "a") { | |
| 89 | if (me.role === "anon") said = "Sign in to add a note."; | |
| 90 | else typing = ""; | |
| 91 | } | |
| 92 | if (key === "p") { | |
| 93 | if (me.role !== "admin") said = "Only an admin can list apps."; | |
| 94 | else { | |
| 95 | const apps = listApps(db, me); | |
| 96 | said = !apps.ok | |
| 97 | ? apps.message | |
| 98 | : apps.value.length === 0 | |
| 99 | ? "No apps registered." | |
| 100 | : apps.value | |
| 101 | .map( | |
| 102 | (a) => | |
| 103 | `${a.id} (gen ${a.generation}${a.revoked ? ", revoked" : ""})`, | |
| 104 | ) | |
| 105 | .join(" · "); | |
| 106 | } | |
| 107 | } | |
| 108 | if (key === "d") { | |
| 109 | const note = rows[cursor]; | |
| 110 | if (note === undefined) said = "Nothing to remove."; | |
| 111 | else if (!canDelete(me, note)) | |
| 112 | said = "Only the author or an admin can remove a note."; | |
| 113 | else { | |
| 114 | const removed = deleteNote(db, me, { id: note.id }); | |
| 115 | if (!removed.ok) said = removed.message; | |
| 116 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); | |
| 117 | } | |
| 118 | } | |
| 119 | ======= | |
| 120 | >>>>>>> origin/main | |
| 61 | 121 | draw(); |
| 62 | 122 | }); |
| 63 | 123 | process.stdout.on("resize", draw); |
static/app.css +13
| @@ -149,6 +149,19 @@ code, | ||
| 149 | 149 | .event time { |
| 150 | 150 | white-space: nowrap; |
| 151 | 151 | } |
| 152 | .issued-key { | |
| 153 | border-color: var(); | |
| 154 | } | |
| 155 | .key { | |
| 156 | margin: var() 0; | |
| 157 | padding: var(); | |
| 158 | overflow-wrap: anywhere; | |
| 159 | white-space: pre-wrap; | |
| 160 | background: var(); | |
| 161 | border: 1px solid var(); | |
| 162 | border-radius: var(); | |
| 163 | user-select: all; | |
| 164 | } | |
| 152 | 165 | .empty { |
| 153 | 166 | padding: var() var(); |
| 154 | 167 | text-align: center; |
static/live.js +10
| @@ -4,6 +4,16 @@ | ||
| 4 | 4 | // the pushed data with textContent, never innerHTML — that data came from |
| 5 | 5 | // another app on the forge, and is never trusted as markup. |
| 6 | 6 | |
| 7 | // Registering or rotating an app renders this same response directly | |
| 8 | // instead of redirecting, so the address bar still holds a POST path like | |
| 9 | // /apps or /apps/:id/rotate — neither a page. Replace it with "/", the page | |
| 10 | // this body actually is, so a reload re-GETs it instead of resubmitting the | |
| 11 | // form (and rotating again), and refresh()'s fetch(location.href) below | |
| 12 | // keeps hitting a real page instead of a 404 forever after. | |
outdated
Changes requested — one line. Both findings from the last round are fixed, and I verified them rather than read them:
The one thing to fix: Make it Two notes, neither blocking:
The terminal staying read-only for apps is fine by me — you said so in the pull and the README, and I have opened #30 for the parity (register, rotate and revoke at the keyboard, a testable seam for |
||
| 13 | if (document.querySelector(".issued-key")) { | |
| 14 | history.replaceState(null, "", "/"); | |
| 15 | } | |
| 16 | ||
| 7 | 17 | let since = ""; |
| 8 | 18 | let tries = 0; |
| 9 | 19 | let source; |
p