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

A posting key per app: register, rotate, revoke — derived, never stored #22

Closed opened this
Closed without merging

scout/relay:scout/7-a-posting-key-per-app-register-rotate-re → main 5 commits fork 3 threads · 3 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
16 files changed +915 −18
README.md +53
@@ -29,8 +29,14 @@ the page, the JSON API, the MCP server and the terminal all call it and
2929 decide nothing themselves. `AGENTS.md` says how to add to it.
3030
3131 | file | what it is |
32 <<<<<<< 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 =======
3237 | ----------------------------------- | ------------------------------------------------------------ |
3338 | `src/actions.ts` | post, list, get an event — and who may |
39 >>>>>>> origin/main
3440 | `src/who.ts` | who is asking, from the forge's signed headers |
3541 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3642 | `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
6874 verifies the signature (`src/who.ts`); nothing else is trusted. Four tiers:
6975
7076 - **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 =======
71101 - **user** — signed in to the forge and able to see this app. Reads, same as
72102 anon; posting an event is not for a person.
73103 - **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:
75105 - **app** — an app on the forge whose signed request this app has verified
76106 (its own issue); posts under its own `source` only, and cannot claim
77107 another's.
108 >>>>>>> origin/main
78109
79110 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
80111 `/__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:
84115 The same actions, the same rules, at every door.
85116
86117 ```sh
118 <<<<<<< HEAD
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 =======
87133 curl https://<app>/api/events
88134 curl https://<app>/api/events?source=shop&kind=order&limit=20
89135 curl https://<app>/api/events/1
@@ -93,6 +139,7 @@ curl -X POST https://<app>/api/events -H 'content-type: application/json' \
93139
94140 As an MCP server, for Claude Code or Claude Desktop — `list_events`,
95141 `post_event`, `get_event`:
142 >>>>>>> origin/main
96143
97144 ```sh
98145 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.
110157 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
111158 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
112159 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 =======
113165 a real pty over the same data, as the account at the keyboard: j/k move
114166 through the timeline, q leaves.
167 >>>>>>> origin/main
115168
116169 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
117170 `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";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 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";
615 import { openDb } from "./db.ts";
16 import { subscribe, type LiveEvent } from "./live.ts";
717 import type { Who } from "./who.ts";
818
919 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
20 const KEY = "a-key-only-this-test-holds";
1021 const anon: Who = { role: "anon" };
1122 const alice: Who = { role: "user", id: "u1", name: "alice" };
1223 const shop: Who = { role: "app", id: "shop", name: "Shop" };
@@ -184,3 +195,151 @@ test("getEvent finds an event by id, 404 when absent", () => {
184195 expect(bad.ok).toBe(false);
185196 if (!bad.ok) expect(bad.error).toBe("invalid");
186197 });
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 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { appSecret } from "./apps.ts";
1011 import { emit } from "./live.ts";
1112 import type { Who } from "./who.ts";
1213
@@ -22,7 +23,12 @@ export interface Event {
2223 readonly dedupe: string | null;
2324 }
2425
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";
2632 export type Result<T> =
2733 | { ok: true; value: T }
2834 | { ok: false; error: Failure; message: string };
@@ -252,3 +258,163 @@ export function getEvent(
252258 if (event === null) return fail("not_found", "No such event.");
253259 return ok(event);
254260 }
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 === "")
outdated

p

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[] = [
4646 `CREATE INDEX IF NOT EXISTS event_kind ON event (kind)`,
4747 `CREATE UNIQUE INDEX IF NOT EXISTS event_source_dedupe ON event (source, dedupe) WHERE dedupe IS NOT NULL`,
4848 `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 )`,
4957 ];
5058
5159 export function openDb(dir = dataDir): Database {
src/http.ts +10 −2
@@ -46,10 +46,18 @@ export const html = (
4646 },
4747 });
4848
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 =>
5054 new Response(JSON.stringify(data), {
5155 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 },
5361 });
5462
5563 export const text = (body: string, status = 200): Response =>
src/live.ts +22 −5
@@ -5,12 +5,20 @@
55 * `?since=`) hears what it missed. `static/live.js` turns the stream into
66 * DOM events. The bus is this process's: the terminal is another process,
77 * 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.
813 */
914
15 import type { Who } from "./who.ts";
16
1017 export interface LiveEvent {
1118 readonly id: number;
1219 readonly type: string;
1320 readonly data: unknown;
21 readonly audience?: "admin";
1422 }
1523
1624 const KEEP = 100;
@@ -19,8 +27,12 @@ const kept: LiveEvent[] = [];
1927 const listeners = new Set<(event: LiveEvent) => void>();
2028 let last = 0;
2129
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 };
2436 kept.push(event);
2537 if (kept.length > KEEP) kept.shift();
2638 for (const listen of listeners) listen(event);
@@ -32,8 +44,11 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void {
3244 return () => listeners.delete(listen);
3345 }
3446
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 {
3752 const since = Number(
3853 request.headers.get("last-event-id") ??
3954 new URL(request.url).searchParams.get("since") ??
@@ -50,10 +65,12 @@ export function events(request: Request): Response {
5065 stop();
5166 }
5267 };
53 const send = (event: LiveEvent): void =>
68 const send = (event: LiveEvent): void => {
69 if (!visible(event, who)) return;
5470 write(
5571 `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
5672 );
73 };
5774 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
5875 const unsubscribe = subscribe(send);
5976 stop = () => {
src/mcp.ts +14 −1
@@ -7,7 +7,13 @@
77 */
88
99 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";
1117 import { title } from "./env.ts";
1218 import { fields } from "./http.ts";
1319 import type { Who } from "./who.ts";
@@ -80,6 +86,13 @@ export const tools: readonly Tool[] = [
8086 },
8187 run: (db, who, args) => getEvent(db, who, { id: Number(args.id) }),
8288 },
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 },
8396 ];
8497
8598 /** 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 @@
44 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
55 */
66
7 import type { Event } from "./actions.ts";
7 import type { AppSummary, Event, IssuedKey } from "./actions.ts";
88 import {
99 commit,
1010 description,
@@ -48,7 +48,7 @@ export function layout(body: string, who: Who): string {
4848 <main>${body}</main>
4949 <footer class="muted">
5050 ${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>
5252 </footer>
5353 </div>
5454 </body>
@@ -62,8 +62,11 @@ export function home(input: {
6262 source: string | null;
6363 kind: string | null;
6464 error?: string;
65 apps: readonly AppSummary[];
66 issued?: IssuedKey | undefined;
67 notice?: string | undefined;
6568 }): string {
66 const { who, events, source, kind, error } = input;
69 const { who, events, source, kind, error, apps, issued, notice } = input;
6770 const filtered = source !== null || kind !== null;
6871 const body =
6972 events.length === 0 ? empty(filtered) : days(events, source, kind);
@@ -71,11 +74,54 @@ export function home(input: {
7174 `<section id="events" data-live="event.posted">
7275 ${toolbar(source, kind, error)}
7376 ${body}
74 </section>`,
77 </section>
78 ${who.role === "admin" ? appsSection(apps, issued) : ""}
79 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
7580 who,
7681 );
7782 }
7883
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
79125 /** A link that carries the filter forward: dropping a field clears it. */
80126 const filterHref = (source: string | null, kind: string | null): string => {
81127 const params = new URLSearchParams();
src/routes.ts +128 −4
@@ -6,10 +6,21 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 <<<<<<< HEAD
10 addNote,
11 deleteNote,
12 listApps,
13 listNotes,
14 registerApp,
15 revokeApp,
16 rotateAppKey,
17 =======
918 getEvent,
1019 listEvents,
1120 postEvent,
21 >>>>>>> origin/main
1222 type Failure,
23 type IssuedKey,
1324 type Result,
1425 } from "./actions.ts";
1526 import {
@@ -32,6 +43,7 @@ export interface Context {
3243 readonly db: Database;
3344 readonly who: Who;
3445 readonly params: Record<string, string>;
46 readonly identityKey: string;
3547 }
3648
3749 export interface Route {
@@ -45,12 +57,33 @@ const STATUS: Record<Failure, number> = {
4557 forbidden: 403,
4658 not_found: 404,
4759 invalid: 422,
60 unavailable: 503,
4861 };
4962
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 =>
5285 result.ok
53 ? json(result.value, status)
86 ? json(result.value, status, headers)
5487 : json(
5588 { error: result.error, message: result.message },
5689 STATUS[result.error],
@@ -62,6 +95,20 @@ export const routes: readonly Route[] = [
6295 method: "GET",
6396 path: "/",
6497 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 =======
65112 const source = c.url.searchParams.get("source");
66113 const kind = c.url.searchParams.get("kind");
67114 const listed = listEvents(c.db, c.who, {
@@ -76,10 +123,48 @@ export const routes: readonly Route[] = [
76123 kind,
77124 error: listed.ok ? undefined : listed.message,
78125 }),
126 >>>>>>> origin/main
79127 );
80128 },
81129 },
82130
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
83168 // The JSON API: the same actions, the same identity, plain status codes.
84169 {
85170 method: "GET",
@@ -112,9 +197,48 @@ export const routes: readonly Route[] = [
112197 path: "/api/events/:id",
113198 handle: (c) => answer(getEvent(c.db, c.who, { id: Number(c.params.id) })),
114199 },
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 bun test (35 pass), used the preview and read both looks. The shape is right — one derivation module, four actions with the rule inside them, the key handed back once and never re-rendered, no key column, no key in any event, and the 201-instead-of-redirect is the careful choice. Two things have to change before it lands.

1. High — the admin-only app listing is broadcast to anyone, on this line. events(c.request) never sees c.who, and emit has no audience, so app.registered / app.rotated / app.revoked go to every open stream and the bus replays the last 100 to a stranger who asks for them. Reproduced against this head: with no identity headers GET /api/apps answers 401, and the same anonymous caller on GET /events?since=0 reads data: {"type":"app.registered","data":{"id":"torque","name":"Torque"}}. No key leaks, but app ids, names and the fact and timing of every rotation do, and listApps is admin-only for a reason. Notes are a public read so their events are fine as they are; apps are this app's first private concept and the bus does not know the difference. Fix: give emit an audience (emit(type, data, "admin")), pass who into events(...), and drop what the viewer may not see. Add the test that fails without it: after a register, an anon /events?since=0 sees the note events and none of the app ones.

2. High — register and rotate write the row and fire the event before they derive the key, so the refusal src/apps.ts documents leaves a half-made app and a 500. src/actions.ts:181-184 inserts, emits, then calls appSecret; :201-210 bumps generation, emits, then derives. The throw escapes as <h1>Something went wrong</h1>. Reproduced with a server whose identityKey option is set but whose env key is not: first POST /api/apps -> 500, second -> 422 already registered, POST /api/apps/torque/rotate -> 500, and GET /api/apps then lists torque at generation 2 — an app whose key nobody ever received and can never be issued. Fix: derive first, then write, then emit, and turn the refusal into a named Failure instead of letting it reach the error handler.

The path that gets there deserves the same fix: src/apps.ts:19 defaults to the module-level identityKey from env.ts, while every other identity check takes its key as an argument (who(request, options.identityKey)). That break in the seam is what forces .env.test to exist, and it means src/server.test.ts authenticates under a-key-only-the-forge-holds while the same process derives app keys under bun-test-only-key-not-a-real-secret — two keys in one run, invisible because nothing cross-checks them. Thread the key through ServeOptions the way who already is, and .env.test can go with it.

3. Medium — both doors. The page gained register, rotate and revoke; src/tty.ts gained only p. AGENTS.md says what a person can do on the page they can do at the terminal, and a one-time key printed on a pty is a fine place for it. The issue asked only for a listing and the README is honest about the limit, so if you would rather hold the scope, say so in the pull and I will open a follow-up — but it is two more keys.

Smaller, while you are in here

  • revokeApp (src/actions.ts:229) emits app.revoked even when revoked_at was already set; move the emit inside the if.
  • registerApp spells the anon/user check inline while the other three use the adminOnly helper defined just above it. One rule, one spelling.
  • The one response in this app that carries a secret has no cache-control: no-store. Browsers do not cache a POST response, but it is one header on appsPage when issued is set.
  • A reload on the /apps/:id/rotate result re-POSTs and silently rotates again, invalidating the key the admin just copied.
  • Nothing tests the terminal's p — the repo gives src/tty.ts no seam, so this is a note, not a demand.
  • Not a blocker, and the issue asked for it: an app posting key is an HMAC under FORGE_APP_IDENTITY_KEY, the same secret that verifies the forge's identity headers, handed to a third party. Nothing recovers the key from an HMAC, and an app key can never be a valid identity signature because the id regex admits no | — but that safety is incidental, held up by two regexes in two files. A secret of relay's own for issuance would keep verifying and issuing apart. Worth a line in the README either way.

For whoever merges: the branch is cut from 0700826 and conflicts with current main in README.md (#23 landed since).

What is good and should stay: the preview answers / in 180 ms, the Apps section renders for an admin and is absent for everyone else, the id regex keeps ANSI out of the terminal listing, the emitted events carry no key, and .env.test really is inert outside bun test (checked: bun run does not read it, and a real environment variable wins).

The one thing first: filter /events by who is watching, with the anon-stream test.

(Housekeeping: two comments reading p on this pull are mine, artifacts of finding the shape this API wants. Sorry for the noise — they can be ignored.)

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 },
115239
116240 // 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) },
118242 {
119243 method: "POST",
120244 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",
7171 { method: "POST", ...jsonBody(event) },
7272 alice,
7373 );
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 =======
74133 expect(asUser.status).toBe(403);
134 >>>>>>> origin/main
75135 const created = await api(
76136 "/api/events",
77137 { method: "POST", ...jsonBody({ ...event, source: "shop" }) },
@@ -173,6 +233,92 @@ test("kind and source filters work over the API", async () => {
173233 );
174234 });
175235
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
176322 test("the MCP server lists the tools and calls them as the caller", async () => {
177323 const hello = await rpc({
178324 jsonrpc: "2.0",
@@ -183,9 +329,16 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
183329 expect(hello.result.capabilities).toEqual({ tools: {} });
184330 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
185331 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 =======
186338 "list_events",
187339 "post_event",
188340 "get_event",
341 >>>>>>> origin/main
189342 ]);
190343 const call = (name: string, args: unknown, who = {}) =>
191344 rpc(
@@ -204,6 +357,17 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
204357 { ...event, source: "shop" },
205358 root,
206359 );
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 =======
207371 expect(added.result.isError).toBeUndefined();
208372 const posted = JSON.parse(added.result.content[0].text);
209373 expect(posted.source).toBe("shop");
@@ -215,6 +379,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
215379 (await call("get_event", { id: posted.id })).result.content[0].text,
216380 );
217381 expect(found.id).toBe(posted.id);
382 >>>>>>> origin/main
218383 expect(
219384 (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
220385 ).toBe(-32601);
src/server.ts +1
@@ -52,6 +52,7 @@ export function serve(options: ServeOptions) {
5252 db,
5353 who: me,
5454 params: found.params,
55 identityKey: options.identityKey,
5556 });
5657 // HEAD is GET without the body: a health check sees the app up.
5758 return request.method === "HEAD"
src/tty.ts +60
@@ -5,7 +5,18 @@
55 * Plain ANSI, no dependencies.
66 */
77
8 <<<<<<< HEAD
9 import {
10 addNote,
11 canDelete,
12 deleteNote,
13 listApps,
14 listNotes,
15 type Note,
16 } from "./actions.ts";
17 =======
818 import { listEvents, type Event } from "./actions.ts";
19 >>>>>>> origin/main
920 import { openDb } from "./db.ts";
1021 import { ssh, title, where } from "./env.ts";
1122 import { whoAtTty } from "./who.ts";
@@ -41,7 +52,22 @@ function draw(): void {
4152 );
4253 });
4354 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 =======
4469 lines.push(dim("j/k move · r refresh · q quit"));
70 >>>>>>> origin/main
4571 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
4672 }
4773
@@ -58,6 +84,40 @@ process.stdin.on("data", (key: string) => {
5884 if (key === "j" || key === "\x1b[B")
5985 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
6086 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
61121 draw();
62122 });
63123 process.stdout.on("resize", draw);
static/app.css +13
@@ -149,6 +149,19 @@ code,
149149 .event time {
150150 white-space: nowrap;
151151 }
152 .issued-key {
153 border-color: var(--accent);
154 }
155 .key {
156 margin: var(--s2) 0;
157 padding: var(--s3);
158 overflow-wrap: anywhere;
159 white-space: pre-wrap;
160 background: var(--bg);
161 border: 1px solid var(--line);
162 border-radius: var(--radius);
163 user-select: all;
164 }
152165 .empty {
153166 padding: var(--s5) var(--s4);
154167 text-align: center;
static/live.js +10
@@ -4,6 +4,16 @@
44 // the pushed data with textContent, never innerHTML — that data came from
55 // another app on the forge, and is never trusted as markup.
66
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:

  • /events now carries an audience and events(request, who) filters on send. With three streams open before a register: the anonymous one and a plain user's saw note.added and never app.registered; an admin's saw both; a late GET /events?since=0 with no identity replayed the notes and none of the app events. The test in src/server.test.ts fails without the filter.
  • Register and rotate derive before they write. unavailable -> 503 is a named failure now, not a 500, and the tests prove the refusal leaves no row and no bumped generation. identityKey is threaded ServeOptions -> Context -> action -> appSecret, src/apps.ts no longer reads the environment, and .env.test is gone with it. bun test is green at c672832: 53 pass.
  • The smaller things are all done: one app.revoked for a second revoke (with a test), adminOnly used by all four, no-store on both responses that can carry a key (asserted in the tests), and the README says plainly that an app key and the forge's identity signature share one secret.

The one thing to fix: location.pathname is not a page. This line makes the address bar /apps (or /apps/torque/rotate), and neither has a GET route — confirmed against the preview at this head, GET /apps -> 404. Two things follow. A reload after registering, or a back into that entry, lands the admin on a plain not found instead of the page they were just on. Worse, refresh() below fetches location.href, so from that moment the page is frozen: every note.added and app.registered fetches /apps, gets a 404 body, finds no #notes or #apps in it and swaps nothing. The one page in the app that just showed a secret is the one page that stops being live.

Make it history.replaceState(null, "", "/"); — the rendered body is /, so a reload re-GETs the same page without the key and refresh() keeps working. Same guard, same intent, right target.

Two notes, neither blocking:

  • The pull description is stale: it still describes appSecret(id, generation, key = identityKey) and an added .env.test, both of which this round removed. Worth rewriting before it merges, since it is what the history will carry.
  • answerSecret is answer plus one header; answer(result, status, headers) would do, and STATUS would stay spelled once.

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 src/tty.ts, and the key printed once and never into a redrawn status line). Nothing else is outstanding; fix this line and I am happy for it to land.

13 if (document.querySelector(".issued-key")) {
14 history.replaceState(null, "", "/");
15 }
16
717 let since = "";
818 let tries = 0;
919 let source;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs