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

GET /forges/<name>/ready says whether a new forge answers yet, and never holds its claim code #11214

Merged opened this

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

Merged

scout/forge:scout/11201-get-forges-name-ready-says-whether-a-new → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/11201-get-forges-name-ready-says-whether-a-new was merged into main as 6389db1.

Review

Sign in to review this change.
11 files changed +229 −2
docs/features/forge-ready.md added +16
@@ -0,0 +1,16 @@
1 # Does a forge I sent answer yet?
2
3 `GET /forges/<name>/ready` answers `{"ready":true}` or `{"ready":false}` for a
4 forge this one made, so a page can wait before sending someone to a child that
5 is still booting.
6
7 - **How.** The parent probes the child's `/healthz` (800 ms at most) and says
8 only yes or no: never the claim code, never the child's address.
9 - **Poll** every two seconds: `curl https://<forge>/forges/<name>/ready`.
10 - **Errors.** A name that is not a child's name, or that this forge never made,
11 is a `404 {"error":"not_found"}`. More than 60 asks a minute from one address
12 is a `429` with `retry-after`. Every answer is `no-store`.
13 - **In the terminal:** open the palette (ctrl-k), "Does a forge answer?", pick
14 a forge you sent; a toast says *answers* or *does not answer yet*. It uses the
15 same `childReady` (`src/spawn/ready.ts`) as the route.
16 - Tests: `test/forge-ready.test.ts`.
src/actions/spawn.ts +6
@@ -737,6 +737,12 @@ const childOf = (app: App, ledger: Ledger): Child => ({
737737 placementNote: ledger.placementNote ?? null,
738738 });
739739
740 /** One child by name, or undefined when this forge made none of that name. */
741 export async function childNamed(app: App, name: string): Promise<Child | undefined> {
742 const ledger = await readLedger(app, name);
743 return ledger === undefined ? undefined : childOf(app, ledger);
744 }
745
740746 /** The children this forge has made or begun, from its ledger, by name. */
741747 export async function listChildren(app: App): Promise<Child[]> {
742748 const dir = join(app.config.dataDir, "spawn");
src/http/server.ts +2
@@ -269,6 +269,7 @@ import {
269269 composerSubmit,
270270 tellSubmit,
271271 } from "../web/studio/actions.ts";
272 import { forgeReady } from "../web/pages/forge-ready.ts";
272273 import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts";
273274 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
274275 import { familyPage } from "../web/pages/family.ts";
@@ -846,6 +847,7 @@ export function buildRouter(): Router<Handler> {
846847 router.add("POST", "/forges/:name/own-code", forgeOwnCodeSubmit);
847848 router.add("GET", "/forges/:name/remove", forgeRemoveConfirm);
848849 router.add("POST", "/forges/:name/remove", forgeRemoveSubmit);
850 router.add("GET", "/forges/:name/ready", forgeReady);
849851 router.add("GET", "/forges/new", newForgePage);
850852 router.add("POST", "/forges/new", newForgeSubmit);
851853 // The front page as a stranger sees it, for anyone; a site admin there
src/spawn/door.ts +6
@@ -49,6 +49,12 @@ const MAX_CREW = 100;
4949 const placements = new WeakMap<App, Placement>();
5050 const probes = new WeakMap<App, (url: string) => Probe>();
5151
52 /** The probe this forge asks a child's address with: the test's when one was set, else `/healthz` within `timeoutMs`. */
53 export function probeFor(app: App, url: string, timeoutMs: number): Probe {
54 const swapped = probes.get(app);
55 return swapped ? swapped(url) : probeOf(url, timeoutMs);
56 }
57
5258 /** Tests swap how a new child is asked whether it is live. */
5359 export function useProbe(app: App, probe: (url: string) => Probe): void {
5460 probes.set(app, probe);
src/spawn/live.ts +2 −2
@@ -22,11 +22,11 @@ export const GIVE_UP_MS = 10 * 60_000;
2222 export type Probe = () => Promise<boolean>;
2323
2424 /** A probe of `<url>/healthz`: true on a 200, false on anything else. Never throws. */
25 export function probeOf(url: string): Probe {
25 export function probeOf(url: string, timeoutMs = 2000): Probe {
2626 return async () => {
2727 try {
2828 const res = await fetch(`${url.replace(/\/+$/, "")}/healthz`, {
29 signal: AbortSignal.timeout(2000),
29 signal: AbortSignal.timeout(timeoutMs),
3030 redirect: "manual",
3131 });
3232 await res.body?.cancel();
src/spawn/ready.ts added +19
@@ -0,0 +1,19 @@
1 /**
2 * Whether a forge this one made answers yet: the one answer behind
3 * `GET /forges/<name>/ready` and the terminal's "Does a forge answer?".
4 * Probes the child's `/healthz` through the probe seam; says yes or no, never
5 * the claim code or the address. `undefined` is a name this forge never made.
6 */
7 import { childNamed } from "../actions/spawn.ts";
8 import type { App } from "../app.ts";
9 import { probeFor } from "./door.ts";
10
11 /** A booting child must never hold this forge's loop. */
12 export const READY_PROBE_MS = 800;
13
14 export async function childReady(app: App, name: string, onPhase?: <T>(phase: string, run: () => Promise<T>) => Promise<T>): Promise<boolean | undefined> {
15 const time = onPhase ?? ((_p, run) => run());
16 const child = await time("ledger", () => childNamed(app, name));
17 if (child === undefined) return undefined;
18 return time("probe", () => probeFor(app, child.url, READY_PROBE_MS)());
19 }
src/tui/screens/entries.ts +2
@@ -44,6 +44,7 @@ import { entry as flowsAll } from "./flowsall.ts";
4444 import { entry as front } from "./front.ts";
4545 import { entry as frontMilestones } from "./frontms.ts";
4646 import { entry as forges } from "./forges.ts";
47 import { entry as forgeReady } from "./forgeready.ts";
4748 import { entry as homePins } from "./home_pins.ts";
4849 import { entry as claimStart } from "./claim_start.ts";
4950 import { entry as invites } from "./invites.ts";
@@ -168,6 +169,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
168169 front,
169170 owner,
170171 forges,
172 forgeReady,
171173 childCredit,
172174 originAhead,
173175 profile,
src/tui/screens/forgeready.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * "Does a forge answer?" in the terminal: pick a forge this one sent and be
3 * told whether it answers yet, by the same `childReady` as
4 * `GET /forges/<name>/ready`. Yes or no; the claim code is never shown.
5 */
6
7 import { listChildren } from "../../actions/spawn.ts";
8 import { childReady } from "../../spawn/ready.ts";
9 import type { Ctx } from "../app/app.ts";
10 import { blank, pickerScreen, runAsync } from "./common.ts";
11 import type { PaletteEntry } from "./entries.ts";
12
13 export const entry: PaletteEntry = {
14 id: "forge-ready",
15 title: "Does a forge answer?",
16 hint: "ask whether a forge you sent answers yet",
17 after: ["forges"],
18 // Admin only, as `forges` is: the list names every forge this one sent.
19 offered: (session) => session.user?.isAdmin === true,
outdated

Leak across users: forges gates this list to admins (session.user?.isAdmin === true); a member here gets every sent forge name. Gate the same way, and remove the entry from the member roster test.

20 run: (session, ctx: Ctx) =>
21 runAsync(ctx, async () => {
22 // From the ledger alone: filling the picker probes no child.
23 const rows = await listChildren(session.app);
24 if (rows.length === 0) return void ctx.toast("You have not sent a forge yet");
25 ctx.push(
26 pickerScreen(ctx, blank("Does a forge answer?"), {
27 title: "Which forge?",
28 items: rows.map((r) => ({ id: r.name, label: r.name, hint: "" })),
29 onPick: (ids) => {
30 const name = ids[0];
31 if (name === undefined) return;
32 runAsync(ctx, async () => {
33 const ready = await childReady(session.app, name);
34 if (ready === undefined) return void ctx.toast(`${name}: no such forge`, "danger");
35 ctx.toast(ready ? `${name} answers.` : `${name} does not answer yet.`, ready ? "success" : "danger");
36 });
37 },
38 }),
39 );
40 }),
41 };
src/web/pages/forge-ready.ts added +40
@@ -0,0 +1,40 @@
1 /**

Both doors: add docs/features/forge-ready.md, and a terminal screen in src/tui/ (entry as AGENTS.md says) that reports whether a child answers, using the same childNamed and probeFor.

2 * `GET /forges/<name>/ready`: whether a forge this one made answers yet, as
3 * `{"ready":true|false}`, so a browser can wait before sending a stranger to
4 * a child still booting. The parent probes the child's `/healthz` (the probe
5 * seam of `spawn/door.ts`, 800 ms at most) and says only yes or no: never the
6 * claim code, never the child's address. A name that is not a child's name,
7 * or that this forge never made, is a 404. Strangers are counted per address.
8 */
9 import { RateLimiter } from "../../core/ratelimit.ts";
10 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
11 import { isChildName } from "../../core/proposal_token.ts";
12 import type { App } from "../../app.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { clientBucket } from "../../http/peer.ts";
15 import { json } from "../../http/respond.ts";
16 import { childReady, READY_PROBE_MS } from "../../spawn/ready.ts";
17
18 /** Asks per minute from one address: a browser polling every two seconds fits. */
19 export const READY_PER_MINUTE = 60;
20 export { READY_PROBE_MS };
21
22 const limiters = new WeakMap<App, RateLimiter>();
23 const NO_STORE = { "cache-control": "no-store" };
24
25 export async function forgeReady(ctx: Ctx): Promise<Response> {
26 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
27 let limiter = limiters.get(ctx.app);
28 if (limiter === undefined) {
29 limiter = new RateLimiter({ perMinute: READY_PER_MINUTE, adminPerMinute: READY_PER_MINUTE, enabled: true });
30 limiters.set(ctx.app, limiter);
31 }
32 const verdict = limiter.take(clientBucket(ctx.request), "ro");
33 if (!verdict.allowed)
34 return json({ error: "rate_limited" }, 429, { ...NO_STORE, "retry-after": String(Math.max(1, verdict.retryAfter)) });
35 const name = ctx.params.name ?? "";
36 if (!isChildName(name)) return json({ error: "not_found" }, 404, NO_STORE);
37 const ready = await childReady(ctx.app, name, (phase, run) => timePhaseAsync(ctx.request, phase as "ledger" | "probe", run));
38 if (ready === undefined) return json({ error: "not_found" }, 404, NO_STORE);
39 return json({ ready }, 200, NO_STORE);
40 }
test/forge-ready.test.ts added +93
@@ -0,0 +1,93 @@
1 /** GET /forges/<name>/ready: a child's liveness, never its claim code (#11201). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { serve } from "../src/http/server.ts";
8 import { useProbe } from "../src/spawn/door.ts";
9 import { READY_PER_MINUTE } from "../src/web/pages/forge-ready.ts";
10
11 let dir = "";
12 let app: ReturnType<typeof createApp>;
13 let servers: Awaited<ReturnType<typeof serve>>;
14 let up = false;
15 const asked: string[] = [];
16
17 beforeAll(async () => {
18 dir = await mkdtemp(join(tmpdir(), "forge-ready-"));
19 app = createApp({ dataDir: dir, port: 0 });
20 await mkdir(join(dir, "spawn"), { recursive: true });
21 await writeFile(join(dir, "spawn", "acme.json"), JSON.stringify({ name: "acme", bodyId: "b1", done: true, claimToken: "SECRET-CLAIM-CODE" }));
22 useProbe(app, (url) => async () => {
23 asked.push(url);
24 return up;
25 });
26 servers = await serve(app);
27 });
28
29 afterAll(async () => {
30 servers.stop();
31 app.close();
32 await rm(dir, { recursive: true, force: true });
33 });
34
35 const ready = (name: string) => fetch(`${servers.url}/forges/${name}/ready`);
36
37 test("a child that does not answer yet is not ready; once it does, it is", async () => {
38 up = false;
39 let res = await ready("acme");
40 expect(res.status).toBe(200);
41 expect(await res.json()).toEqual({ ready: false });
42 up = true;
43 res = await ready("acme");
44 expect(await res.json()).toEqual({ ready: true });
45 expect(asked.length).toBe(2);
46 });
47
48 test("the answer is not cached, holds no token and keeps the security floor", async () => {
49 const res = await ready("acme");
50 expect(res.headers.get("cache-control")).toBe("no-store");
51 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
52 expect(res.headers.get("content-security-policy")).not.toBeNull();
53 expect(res.headers.get("server-timing") ?? "").toContain("probe");
54 expect(await res.text()).not.toContain("SECRET-CLAIM-CODE");
55 });
56
57 test("a name that is not a child's, or was never made here, is a 404 and nothing is probed", async () => {
58 const before = asked.length;
59 for (const name of ["Acme", "-acme", "acme-", "a_b", "x".repeat(41), "ghost"]) {
60 const res = await ready(name);
61 expect(res.status).toBe(404);
62 expect(res.headers.get("cache-control")).toBe("no-store");
63 }
64 expect(asked.length).toBe(before);
65 });
66
67 test("one address is limited, and the refusal names the wait", async () => {
68 let refused: Response | undefined;
69 for (let i = 0; i < READY_PER_MINUTE + 5 && refused === undefined; i++) {
70 const res = await ready("acme");
71 if (res.status === 429) refused = res;
72 }
73 expect(refused?.status).toBe(429);
74 expect(Number(refused?.headers.get("retry-after"))).toBeGreaterThan(0);
75 expect(refused?.headers.get("cache-control")).toBe("no-store");
76 });
77
78 test("the terminal's answer is the route's: childReady says yes, no, or unknown", async () => {
79 const { childReady } = await import("../src/spawn/ready.ts");
80 up = true;
81 expect(await childReady(app, "acme")).toBe(true);
82 up = false;
83 expect(await childReady(app, "acme")).toBe(false);
84 expect(await childReady(app, "nobody")).toBeUndefined();
85 });
86
87 test("the terminal door is the forges list's: offered to an admin, never to a member or a visitor", async () => {
88 const { entry } = await import("../src/tui/screens/forgeready.ts");
89 const as = (user: unknown) => ({ app, user }) as unknown as Parameters<typeof entry.offered>[0];
90 expect(entry.offered(as({ isAdmin: true }))).toBe(true);
91 expect(entry.offered(as({ isAdmin: false }))).toBe(false);
92 expect(entry.offered(as(undefined))).toBe(false);
93 });
test/palette-roster.test.ts +2
@@ -180,6 +180,7 @@ test("a site-admin person is offered the whole roster", () => {
180180 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
181181 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
182182 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
183 ["forge-ready", "Does a forge answer?", "ask whether a forge you sent answers yet"],
183184 ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"],
184185 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
185186 ["profile", "Your profile", "display name and email"],
@@ -259,6 +260,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
259260 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
260261 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
261262 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
263 ["forge-ready", "Does a forge answer?", "ask whether a forge you sent answers yet"],
262264 ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"],
263265 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
264266 ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs