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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11201-get-forges-name-ready-says-whether-a-new → main 5 commits fork 2 threads · 2 unresolved
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 => ({ | ||
| 737 | 737 | placementNote: ledger.placementNote ?? null, |
| 738 | 738 | }); |
| 739 | 739 | |
| 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 | ||
| 740 | 746 | /** The children this forge has made or begun, from its ledger, by name. */ |
| 741 | 747 | export async function listChildren(app: App): Promise<Child[]> { |
| 742 | 748 | const dir = join(app.config.dataDir, "spawn"); |
src/http/server.ts +2
| @@ -269,6 +269,7 @@ import { | ||
| 269 | 269 | composerSubmit, |
| 270 | 270 | tellSubmit, |
| 271 | 271 | } from "../web/studio/actions.ts"; |
| 272 | import { forgeReady } from "../web/pages/forge-ready.ts"; | |
| 272 | 273 | import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts"; |
| 273 | 274 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 274 | 275 | import { familyPage } from "../web/pages/family.ts"; |
| @@ -846,6 +847,7 @@ export function buildRouter(): Router<Handler> { | ||
| 846 | 847 | router.add("POST", "/forges/:name/own-code", forgeOwnCodeSubmit); |
| 847 | 848 | router.add("GET", "/forges/:name/remove", forgeRemoveConfirm); |
| 848 | 849 | router.add("POST", "/forges/:name/remove", forgeRemoveSubmit); |
| 850 | router.add("GET", "/forges/:name/ready", forgeReady); | |
| 849 | 851 | router.add("GET", "/forges/new", newForgePage); |
| 850 | 852 | router.add("POST", "/forges/new", newForgeSubmit); |
| 851 | 853 | // 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; | ||
| 49 | 49 | const placements = new WeakMap<App, Placement>(); |
| 50 | 50 | const probes = new WeakMap<App, (url: string) => Probe>(); |
| 51 | 51 | |
| 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 | ||
| 52 | 58 | /** Tests swap how a new child is asked whether it is live. */ |
| 53 | 59 | export function useProbe(app: App, probe: (url: string) => Probe): void { |
| 54 | 60 | probes.set(app, probe); |
src/spawn/live.ts +2 −2
| @@ -22,11 +22,11 @@ export const GIVE_UP_MS = 10 * 60_000; | ||
| 22 | 22 | export type Probe = () => Promise<boolean>; |
| 23 | 23 | |
| 24 | 24 | /** 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 { | |
| 26 | 26 | return async () => { |
| 27 | 27 | try { |
| 28 | 28 | const res = await fetch(`${url.replace(/\/+$/, "")}/healthz`, { |
| 29 | signal: AbortSignal.timeout(2000), | |
| 29 | signal: AbortSignal.timeout(timeoutMs), | |
| 30 | 30 | redirect: "manual", |
| 31 | 31 | }); |
| 32 | 32 | 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"; | ||
| 44 | 44 | import { entry as front } from "./front.ts"; |
| 45 | 45 | import { entry as frontMilestones } from "./frontms.ts"; |
| 46 | 46 | import { entry as forges } from "./forges.ts"; |
| 47 | import { entry as forgeReady } from "./forgeready.ts"; | |
| 47 | 48 | import { entry as homePins } from "./home_pins.ts"; |
| 48 | 49 | import { entry as claimStart } from "./claim_start.ts"; |
| 49 | 50 | import { entry as invites } from "./invites.ts"; |
| @@ -168,6 +169,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 168 | 169 | front, |
| 169 | 170 | owner, |
| 170 | 171 | forges, |
| 172 | forgeReady, | |
| 171 | 173 | childCredit, |
| 172 | 174 | originAhead, |
| 173 | 175 | 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, | |
| 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", () => { | ||
| 180 | 180 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 181 | 181 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 182 | 182 | ["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"], | |
| 183 | 184 | ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"], |
| 184 | 185 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 185 | 186 | ["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", | ||
| 259 | 260 | ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"], |
| 260 | 261 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 261 | 262 | ["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"], | |
| 262 | 264 | ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"], |
| 263 | 265 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 264 | 266 | ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"], |
Leak across users:
forgesgates 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.