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

Relay posts its own events, starting with its own app registry #62

Merged opened this

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

Merged

scout/relay:scout/33-relay-posts-its-own-events-starting-with → main 1 commit fork

  • Merged. scout/33-relay-posts-its-own-events-starting-with was merged into main as e2f05df.

Review

Sign in to review this change.
5 files changed +254 −9
README.md +36 −3
@@ -73,9 +73,42 @@ at the moment it's issued. Rotating (`rotateAppKey`) bumps the generation,
7373 which is what makes the previous key stop verifying — there is no key to
7474 delete. Revoking (`revokeApp`) sets a column rather than removing the row,
7575 because a derived key stays computable forever and the check that matters
76 is whether it's still current, not whether the row exists. Nothing yet
77 calls these but tests: no route, page or terminal command reaches them in
78 this change.
76 is whether it's still current, not whether the row exists. `relay` itself
77 is a reserved id — nothing can register an app under it — because relay is
78 a poster on its own timeline (below), and its `source` must stay
79 unforgeable. No route, page or terminal command reaches these actions yet:
80 only an admin working directly against `src/actions.ts` (or a future door)
81 can register, rotate or revoke.
82
83 ## relay posts its own events
84
85 An event hub with an empty timeline has nothing to show. relay is an app
86 on this forge like any other, and it posts about its own actions the same
87 way anything else does — through `postEvent`, with an identity it builds
88 for itself (`RELAY` in `src/actions.ts`), never a bare `INSERT`. Two kinds:
89
90 - `app.registered`, `app.rotated`, `app.revoked` — one each time an admin
91 registers, rotates or revokes an app (`src/actions.ts`). `subject` is the
92 app's id; `summary` is one line naming the app and who did it, e.g.
93 "juniper registered bazaar". Never the key, the secret or anything
94 derived from either — only the id and the two names ever reach the row.
95 - `relay.deployed` — one per boot, posted in the web process only (never
96 the terminal), after the listener is up (`src/server.ts`). `subject` is
97 the short commit; `summary` names the environment, e.g. "production,
98 commit bbf8ef9"; `dedupe` is the commit, so a restart, a crash loop or a
99 second process never repeats it. A local run with no commit configured
100 posts nothing.
101
102 Posting is best-effort: if it fails, the failure is logged and the write
103 it followed — a registration, a rotation, a revocation — still succeeds.
104 An event on a write path is a second thing that can fail, and it must
105 never be the reason the first thing does.
106
107 Naming the admin who acted puts a forge username on a public timeline.
108 That's deliberate, not an oversight: it's the same username the forge
109 already shows next to "Sign out" on relay's own page, nothing more
110 identifying, and it's what makes the timeline a true account of who did
111 what.
79112
80113 ## Who you are
81114
src/actions.test.ts +62 −1
@@ -1,4 +1,4 @@
1 import { expect, test } from "bun:test";
1 import { expect, spyOn, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
@@ -7,6 +7,7 @@ import {
77 listApps,
88 listEvents,
99 postEvent,
10 RELAY,
1011 registerApp,
1112 revokeApp,
1213 rotateAppKey,
@@ -296,3 +297,63 @@ test("listApps is admin-only and never carries a key", () => {
296297 for (const app of listed.value) expect(Object.keys(app)).not.toContain("key");
297298 expect(JSON.stringify(listed.value)).not.toContain(KEY);
298299 });
300
301 test("relay's own id is reserved; nothing can register as it", () => {
302 const result = registerApp(db, root, { id: "relay", name: "Impersonator" }, KEY);
303 expect(result.ok).toBe(false);
304 if (!result.ok) expect(result.error).toBe("invalid");
305 });
306
307 test("registering an app posts relay's own event about it, with no key in it", () => {
308 const result = registerApp(db, root, { id: "beacon", name: "Beacon" }, KEY);
309 expect(result.ok).toBe(true);
310 if (!result.ok) return;
311 const events = listEvents(db, anon, { source: RELAY.id, kind: "app.registered" });
312 expect(events.ok).toBe(true);
313 const posted = events.ok ? events.value.find((e) => e.subject === "beacon") : undefined;
314 expect(posted?.source).toBe("relay");
315 expect(posted?.summary).toBe("root registered Beacon");
316 expect(JSON.stringify(posted)).not.toContain(result.value.key);
317 });
318
319 test("rotating an app's key posts relay's own event about it, with no key in it", () => {
320 registerApp(db, root, { id: "dynamo", name: "Dynamo" }, KEY);
321 const rotated = rotateAppKey(db, root, { id: "dynamo" }, KEY);
322 expect(rotated.ok).toBe(true);
323 if (!rotated.ok) return;
324 const events = listEvents(db, anon, { source: RELAY.id, kind: "app.rotated" });
325 const posted = events.ok ? events.value.find((e) => e.subject === "dynamo") : undefined;
326 expect(posted?.summary).toBe("root rotated Dynamo");
327 expect(JSON.stringify(posted)).not.toContain(rotated.value.key);
328 });
329
330 test("revoking an app posts relay's own event once, not again on a second revoke", () => {
331 registerApp(db, root, { id: "lever", name: "Lever" }, KEY);
332 const first = revokeApp(db, root, { id: "lever" });
333 expect(first.ok).toBe(true);
334 const events = listEvents(db, anon, { source: RELAY.id, kind: "app.revoked" });
335 const countAfterFirst = events.ok
336 ? events.value.filter((e) => e.subject === "lever").length
337 : -1;
338 expect(countAfterFirst).toBe(1);
339 const posted = events.ok ? events.value.find((e) => e.subject === "lever") : undefined;
340 expect(posted?.summary).toBe("root revoked Lever");
341 const second = revokeApp(db, root, { id: "lever" });
342 expect(second.ok).toBe(true);
343 const after = listEvents(db, anon, { source: RELAY.id, kind: "app.revoked" });
344 const countAfterSecond = after.ok
345 ? after.value.filter((e) => e.subject === "lever").length
346 : -1;
347 expect(countAfterSecond).toBe(countAfterFirst);
348 });
349
350 test("a registration succeeds even when relay's own event about it fails to post", () => {
351 const wordyAdmin: Who = { role: "admin", id: "u-wordy", name: "x".repeat(200) };
352 const spy = spyOn(console, "error").mockImplementation(() => {});
353 const result = registerApp(db, wordyAdmin, { id: "verbose", name: "Verbose" }, KEY);
354 expect(result.ok).toBe(true);
355 const events = listEvents(db, anon, { source: RELAY.id, kind: "app.registered" });
356 expect(events.ok && events.value.some((e) => e.subject === "verbose")).toBe(false);
357 expect(spy).toHaveBeenCalled();
358 spy.mockRestore();
359 });
src/actions.ts +43 −2
@@ -71,6 +71,30 @@ export interface PostEventInput {
7171 readonly dedupe?: unknown;
7272 }
7373
74 /**
75 * relay's own identity: it posts about itself through `postEvent`, the same
76 * door as everything else, never a bare `INSERT`. "relay" is reserved
77 * (`registerApp` below) so nothing else can ever claim this source.
78 */
79 export const RELAY = { role: "app", id: "relay", name: "relay" } satisfies Who;
80
81 /**
82 * relay posting about itself is best-effort: a registration, rotation or
83 * revocation must never fail because its own event couldn't be written, so
84 * a refusal is logged and swallowed here rather than surfaced.
85 */
86 export function postRelayEvent(db: Database, input: PostEventInput): void {
87 try {
88 const result = postEvent(db, RELAY, input);
89 if (!result.ok)
90 console.error(
91 `relay: could not post its own ${String(input.kind)} event: ${result.message}`,
92 );
93 } catch (err) {
94 console.error(`relay: could not post its own ${String(input.kind)} event:`, err);
95 }
96 }
97
7498 export function postEvent(
7599 db: Database,
76100 who: Who,
@@ -282,7 +306,7 @@ export interface IssuedKey {
282306 }
283307
284308 type AdminGate =
285 | { ok: true; id: string }
309 | { ok: true; id: string; name: string }
286310 | { ok: false; error: Failure; message: string };
287311
288312 /** The rule every app action shares: only an admin manages apps. */
@@ -299,7 +323,7 @@ function requireAdmin(who: Who): AdminGate {
299323 error: "forbidden",
300324 message: "Only an admin may manage apps.",
301325 };
302 return { ok: true, id: who.id };
326 return { ok: true, id: who.id, name: who.name };
303327 }
304328
305329 const toSummary = (row: AppRow): AppSummary => ({
@@ -333,6 +357,8 @@ export function registerApp(
333357 "invalid",
334358 "id is lowercase letters, digits and hyphens, up to 32 characters.",
335359 );
360 if (id === RELAY.id)
361 return fail("invalid", "id is reserved.");
336362
337363 const name = String(input.name ?? "");
338364 if (!isPlainText(name, APP_NAME_MIN, APP_NAME_MAX))
@@ -361,6 +387,11 @@ export function registerApp(
361387 gate.id,
362388 );
363389 emit("app.registered", { id, name });
390 postRelayEvent(db, {
391 kind: "app.registered",
392 subject: id,
393 summary: `${gate.name} registered ${name}`,
394 });
364395 return ok({ id, name, generation: 1, key: secret });
365396 }
366397
@@ -402,6 +433,11 @@ export function rotateAppKey(
402433 row.id,
403434 );
404435 emit("app.rotated", { id: row.id, name: row.name });
436 postRelayEvent(db, {
437 kind: "app.rotated",
438 subject: row.id,
439 summary: `${gate.name} rotated ${row.name}`,
440 });
405441 return ok({ id: row.id, name: row.name, generation, key: secret });
406442 }
407443
@@ -423,6 +459,11 @@ export function revokeApp(
423459 "UPDATE app SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1",
424460 ).run(row.id);
425461 emit("app.revoked", { id: row.id, name: row.name });
462 postRelayEvent(db, {
463 kind: "app.revoked",
464 subject: row.id,
465 summary: `${gate.name} revoked ${row.name}`,
466 });
426467 }
427468 return ok({ id: row.id });
428469 }
src/server.test.ts +85
@@ -484,6 +484,91 @@ test("the adoption kit: the doc and the client, served as text, nothing else rea
484484 expect((await fetch(base + "/docs/adopting.md")).status).toBe(404);
485485 });
486486
487 test("boot posts one relay.deployed event, naming the environment and commit", async () => {
488 const booted = serve({
489 port: 0,
490 dataDir: mkdtempSync(join(tmpdir(), "app-boot-")),
491 identityKey: KEY,
492 commit: "abc1234",
493 where: "production",
494 });
495 try {
496 const at = `http://${booted.hostname}:${booted.port}`;
497 const listed = (await (
498 await fetch(`${at}/api/events?source=relay&kind=relay.deployed`)
499 ).json()) as { subject: string; summary: string; dedupe: string | null }[];
500 expect(listed.length).toBe(1);
501 expect(listed[0]?.subject).toBe("abc1234");
502 expect(listed[0]?.summary).toBe("production, commit abc1234");
503 expect(listed[0]?.dedupe).toBe("abc1234");
504 } finally {
505 booted.stop(true);
506 }
507 });
508
509 test("a restart at the same commit does not repeat the boot event; a new commit posts a second", async () => {
510 const dir = mkdtempSync(join(tmpdir(), "app-boot-"));
511 const first = serve({
512 port: 0,
513 dataDir: dir,
514 identityKey: KEY,
515 commit: "dead001",
516 where: "production",
517 });
518 first.stop(true);
519 const again = serve({
520 port: 0,
521 dataDir: dir,
522 identityKey: KEY,
523 commit: "dead001",
524 where: "production",
525 });
526 try {
527 const at = `http://${again.hostname}:${again.port}`;
528 const sameCommit = (await (
529 await fetch(`${at}/api/events?source=relay&kind=relay.deployed`)
530 ).json()) as { subject: string }[];
531 expect(sameCommit.filter((e) => e.subject === "dead001").length).toBe(1);
532 } finally {
533 again.stop(true);
534 }
535
536 const third = serve({
537 port: 0,
538 dataDir: dir,
539 identityKey: KEY,
540 commit: "dead002",
541 where: "production",
542 });
543 try {
544 const at = `http://${third.hostname}:${third.port}`;
545 const events = (await (
546 await fetch(`${at}/api/events?source=relay&kind=relay.deployed`)
547 ).json()) as { subject: string }[];
548 expect(events.some((e) => e.subject === "dead001")).toBe(true);
549 expect(events.some((e) => e.subject === "dead002")).toBe(true);
550 } finally {
551 third.stop(true);
552 }
553 });
554
555 test("with no commit configured (a local run) no boot event is posted", async () => {
556 const local = serve({
557 port: 0,
558 dataDir: mkdtempSync(join(tmpdir(), "app-boot-")),
559 identityKey: KEY,
560 commit: "",
561 where: "local",
562 });
563 try {
564 const at = `http://${local.hostname}:${local.port}`;
565 const events = (await (await fetch(`${at}/api/events?source=relay`)).json()) as unknown[];
566 expect(events.length).toBe(0);
567 } finally {
568 local.stop(true);
569 }
570 });
571
487572 test("a missing adoption-kit file fails the boot, not a request", () => {
488573 const dir = mkdtempSync(join(tmpdir(), "relay-boot-"));
489574 for (const name of ["src", "client", "static", "docs", "package.json"]) {
src/server.ts +28 −3
@@ -7,8 +7,16 @@
77 * it serves what the forge said; a test calls `serve` with its own.
88 */
99
10 import { postRelayEvent } from "./actions.ts";
1011 import { openDb } from "./db.ts";
11 import { dataDir, identityKey, ssh as envSsh, title, where } from "./env.ts";
12 import {
13 commit as envCommit,
14 dataDir,
15 identityKey,
16 ssh as envSsh,
17 title,
18 where as envWhere,
19 } from "./env.ts";
1220 import { html, json, sameOrigin, text } from "./http.ts";
1321 import { PERSON_WRITES, take } from "./ration.ts";
1422 import { match } from "./routes.ts";
@@ -23,6 +31,10 @@ export interface ServeOptions {
2331 readonly now?: (() => number) | undefined;
2432 /** The terminal command, printed verbatim. Defaults to `FORGE_APP_SSH`; a test brings its own to cover both branches. */
2533 readonly ssh?: string | undefined;
34 /** The short commit the boot event names. Defaults to `FORGE_APP_COMMIT`; a test brings its own to control dedupe. */
35 readonly commit?: string | undefined;
36 /** The environment the boot event names. Defaults to `FORGE_APP_ENV`; a test brings its own. */
37 readonly where?: string | undefined;
2638 }
2739
2840 /**
@@ -57,7 +69,9 @@ export function serve(options: ServeOptions) {
5769 const db = openDb(options.dataDir);
5870 const clock = options.now ?? Date.now;
5971 const ssh = options.ssh ?? envSsh;
60 return Bun.serve({
72 const commit = options.commit ?? envCommit;
73 const where = options.where ?? envWhere;
74 const server = Bun.serve({
6175 port: options.port,
6276 hostname: options.hostname ?? "127.0.0.1",
6377 // A connection that says nothing for a minute is dropped. The event
@@ -108,6 +122,17 @@ export function serve(options: ServeOptions) {
108122 return html("<h1>Something went wrong</h1>", 500);
109123 },
110124 });
125 // One event per commit, however many times this process starts: a
126 // restart, a crash loop or a second process shares the dedupe key. No
127 // commit (a local run) means nothing to name, so nothing is posted.
128 if (commit !== "")
129 postRelayEvent(db, {
130 kind: "relay.deployed",
131 subject: commit,
132 summary: `${where}, commit ${commit}`,
133 dedupe: commit,
134 });
135 return server;
111136 }
112137
113138 if (import.meta.main) {
@@ -118,6 +143,6 @@ if (import.meta.main) {
118143 identityKey,
119144 });
120145 console.log(
121 `${title} listening on http://${server.hostname}:${server.port} (${where})`,
146 `${title} listening on http://${server.hostname}:${server.port} (${envWhere})`,
122147 );
123148 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs