Public: anyone can read this; the crew can change it.
The forge's own release journey is not rate-limited by the forge and does not spend the strangers' daily allowance #12221
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12169-the-forge-s-own-release-journey-is-not-r → main 2 commits fork
6 files
changed +164
−6
src/main.ts +2
| @@ -98,6 +98,7 @@ import { record as recordDegraded } from "./kernel/degraded_history.ts"; | ||
| 98 | 98 | import { vitals } from "./kernel/vitals.ts"; |
| 99 | 99 | import { proveRunning } from "./release/prove.ts"; |
| 100 | 100 | import { walkJourney } from "./release/journey.ts"; |
| 101 | import { proberKey } from "./release/prober.ts"; | |
| 101 | 102 | import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts"; |
| 102 | 103 | import { readLandings } from "./release/landings.ts"; |
| 103 | 104 | import { ownTagsReader } from "./release/own_tags.ts"; |
| @@ -797,6 +798,7 @@ proveRunning({ | ||
| 797 | 798 | url: forgeUrl, |
| 798 | 799 | token: process.env["FORGE_TOKEN"], |
| 799 | 800 | journey: walkJourney, |
| 801 | proberKey: proberKey(app.hookSecret), | |
| 800 | 802 | // The first app's production host: it must never answer as the forge (#6726). |
| 801 | 803 | appHost: (() => { |
| 802 | 804 | const first = listApps(app.db)[0]; |
src/release/journey.ts +12 −2
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | * is its own. |
| 24 | 24 | */ |
| 25 | 25 | import { FIRST_FIVE_MINUTES } from "../design/first_five_minutes.ts"; |
| 26 | import { PROBER_HEADER } from "./prober.ts"; | |
| 26 | 27 | |
| 27 | 28 | export interface JourneyOptions { |
| 28 | 29 | /** The live forge's address, http(s). */ |
| @@ -35,6 +36,11 @@ export interface JourneyOptions { | ||
| 35 | 36 | readonly now?: () => number; |
| 36 | 37 | /** A site admin's token on the host: removes the throwaway forge afterwards. */ |
| 37 | 38 | readonly adminToken?: string; |
| 39 | /** | |
| 40 | * This boot's prober key (`release/prober.ts`): sent to the host only, so the forge's own walk | |
| 41 | * is not a stranger to its rate limits. | |
| 42 | */ | |
| 43 | readonly proberKey?: string; | |
| 38 | 44 | /** Pause between polls, in ms (default 500); how a test keeps its clock quick. */ |
| 39 | 45 | readonly pollMs?: number; |
| 40 | 46 | /** Waits `ms`; `Bun.sleep` when left out. */ |
| @@ -116,8 +122,12 @@ export async function walkJourney(options: JourneyOptions): Promise<JourneyResul | ||
| 116 | 122 | return { ok: false, screens, readyMs, firstActivityMs, failure: "the forge's address is not http or https", forge, removed }; |
| 117 | 123 | const hostUrl = host; |
| 118 | 124 | |
| 119 | const call = (url: URL, init: RequestInit = {}): Promise<Response> => | |
| 120 | doFetch(url.href, { redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...init }); | |
| 125 | // The prober key goes to the host's own origin and nowhere else: never to the new forge. | |
| 126 | const call = (url: URL, init: RequestInit = {}): Promise<Response> => { | |
| 127 | const own = options.proberKey !== undefined && url.origin === hostUrl.origin; | |
| 128 | const headers = own ? { ...(init.headers as Record<string, string> | undefined), [PROBER_HEADER]: options.proberKey } : init.headers; | |
| 129 | return doFetch(url.href, { redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...init, ...(headers === undefined ? {} : { headers }) }); | |
| 130 | }; | |
| 121 | 131 | const form = (url: URL, fields: Record<string, string>, extra: Record<string, string> = {}): Promise<Response> => |
| 122 | 132 | call(url, { |
| 123 | 133 | method: "POST", |
src/release/prober.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The forge's own release prober (#12169): the stranger's journey the release | |
| 3 | * proof walks (`release/journey.ts`) is the forge asking itself, so the | |
| 4 | * stranger rate limits in `spawn/door.ts` do not count it. It is recognised | |
| 5 | * by a key only this boot holds — an HMAC of the per-boot hook secret, never | |
| 6 | * the secret itself, so the header that crosses the public address cannot | |
| 7 | * open the internal listener — and never by a user agent or an address. | |
| 8 | */ | |
| 9 | import { createHmac, timingSafeEqual } from "node:crypto"; | |
| 10 | ||
| 11 | /** The header the journey sets on its own host's requests. */ | |
| 12 | export const PROBER_HEADER = "x-forge-prober"; | |
| 13 | ||
| 14 | /** This boot's prober key, from its hook secret. */ | |
| 15 | export const proberKey = (hookSecret: string): string => | |
| 16 | createHmac("sha256", hookSecret).update("release-journey-prober").digest("hex"); | |
| 17 | ||
| 18 | /** | |
| 19 | * Whether `request` carries this boot's prober key (constant-time); anything else, forged or | |
| 20 | * missing, is a stranger. | |
| 21 | */ | |
| 22 | export function isProber(hookSecret: string, request: Request): boolean { | |
| 23 | const given = Buffer.from(request.headers.get(PROBER_HEADER) ?? ""); | |
| 24 | const want = Buffer.from(proberKey(hookSecret)); | |
| 25 | return hookSecret.length > 0 && given.length === want.length && timingSafeEqual(given, want); | |
| 26 | } |
src/release/prove.ts +6 −1
| @@ -237,6 +237,11 @@ export interface ProveOptions { | ||
| 237 | 237 | readonly signal?: AbortSignal; |
| 238 | 238 | /** Walks the stranger's journey after the smoke checks; the forge passes `walkJourney`, and a test its own. Without it no walk runs. */ |
| 239 | 239 | readonly journey?: typeof walkJourney; |
| 240 | /** | |
| 241 | * This boot's prober key (`release/prober.ts`), handed to the journey so the forge's rate | |
| 242 | * limits know it. | |
| 243 | */ | |
| 244 | readonly proberKey?: string; | |
| 240 | 245 | } |
| 241 | 246 | |
| 242 | 247 | /** Is a proof of `version` due: never tried, or failed `retryAfter` its failures or more ago? */ |
| @@ -307,7 +312,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 307 | 312 | if (failures[0] !== undefined || o.journey === undefined) |
| 308 | 313 | return finish(failures[0] === undefined ? undefined : checkLine(failures[0]), failures[0]?.slow === true || failures[0]?.empty === true || failures[0]?.hung === true); |
| 309 | 314 | // The stranger's journey, the last check: a failed walk is a failed proof, retried on `retryAfter` like any other (one card, paged once). |
| 310 | const walked = await o.journey({ base: o.url, fetch: doFetch, ...(o.token ? { adminToken: o.token } : {}) }); | |
| 315 | const walked = await o.journey({ base: o.url, fetch: doFetch, ...(o.token ? { adminToken: o.token } : {}), ...(o.proberKey ? { proberKey: o.proberKey } : {}) }); | |
| 311 | 316 | return finish(walked.ok ? undefined : `a stranger's journey failed: ${walked.failure ?? "it did not finish"}`, false, false, walked); |
| 312 | 317 | } catch (error) { |
| 313 | 318 | return finish(`the smoke checks could not run: ${String(error)}`); |
src/spawn/door.ts +7 −3
| @@ -36,6 +36,7 @@ import { dryRun, planSpawn, KEY_BUDGET_NOTE, MAX_APPS, type CatalogApp, type Cre | ||
| 36 | 36 | import { bootHostedChild } from "./host.ts"; |
| 37 | 37 | import { cellPlacement, ownsBody } from "./cell.ts"; |
| 38 | 38 | import { cellsAvailable } from "../apps/contain.ts"; |
| 39 | import { isProber } from "../release/prober.ts"; | |
| 39 | 40 | import { ANON_SPAWNS_PER_DAY, checkAnonSpawn, noteAnonSpawn } from "./rate.ts"; |
| 40 | 41 | import { readdirSync, readFileSync } from "node:fs"; |
| 41 | 42 | import { readdir, stat } from "node:fs/promises"; |
| @@ -191,14 +192,17 @@ export async function spawnDoor( | ||
| 191 | 192 | */ |
| 192 | 193 | opts: { readonly paid?: boolean; readonly page?: boolean } = {}, |
| 193 | 194 | ): Promise<Door> { |
| 195 | // The forge's own release prober (`release/prober.ts`) is no stranger: its walk spends no | |
| 196 | // stranger's allowance. | |
| 194 | 197 | const paid = opts.paid === true; |
| 198 | const counted = !paid && !isProber(ctx.app.hookSecret, ctx.request); | |
| 195 | 199 | const anon = user === undefined; |
| 196 | 200 | if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" }); |
| 197 | 201 | // A forge asked for in order to contribute is never made: the fork path costs the host no cell. |
| 198 | 202 | if (wantsToContribute(raw)) return { status: 200, body: contributeAnswer() }; |
| 199 | 203 | // A plan is free of children but not of work — it reads every catalog |
| 200 | 204 | // app it names — so a stranger's are counted per address and per forge. |
| 201 | if (anon && !paid) { | |
| 205 | if (anon && counted) { | |
| 202 | 206 | const verdict = takeDoor(ctx.app, "plan", ctx.request); |
| 203 | 207 | if (!verdict.allowed) |
| 204 | 208 | return { |
| @@ -346,7 +350,7 @@ export async function spawnDoor( | ||
| 346 | 350 | }; |
| 347 | 351 | } |
| 348 | 352 | // A stranger may start only a few children a day; the plan above is free. |
| 349 | if (anon && !paid) { | |
| 353 | if (anon && counted) { | |
| 350 | 354 | const limit = checkAnonSpawn(ctx.app.db, ctx.request); |
| 351 | 355 | if (!limit.allowed) |
| 352 | 356 | return { |
| @@ -385,7 +389,7 @@ export async function spawnDoor( | ||
| 385 | 389 | if (gift !== undefined && !settled) unclaimGift(ctx.app, gift); |
| 386 | 390 | } |
| 387 | 391 | if (!made.ok) return spawnWords(made.error); |
| 388 | if (anon && !paid && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); | |
| 392 | if (anon && counted && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); | |
| 389 | 393 | // The visitor who started (or was given) this forge becomes someone, on the host's side (#11265). |
| 390 | 394 | if (!made.value.existed && !paid) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid"); |
| 391 | 395 | // Its apps come up now, so the first visit after a claim finds them awake. |
test/release-prober.test.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * The forge's own release journey is no stranger (#12169): with this boot's | |
| 3 | * prober key it starts a forge when the strangers' limits are spent and | |
| 4 | * spends none of their allowance; without it, or with a forged or wrong one, | |
| 5 | * it lands on `/?start=rate` like anyone. The journey sends the key to its | |
| 6 | * own host only, never to the new forge. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { ok } from "../src/kernel/result.ts"; | |
| 15 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 16 | import { ANON_SPAWNS_GLOBAL_PER_DAY, noteAnonSpawn, resetAnonSpawns } from "../src/spawn/rate.ts"; | |
| 17 | import { isProber, PROBER_HEADER, proberKey } from "../src/release/prober.ts"; | |
| 18 | import { walkJourney } from "../src/release/journey.ts"; | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-prober-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | servers = await serve(app); | |
| 27 | useProbe(app, () => async () => false); | |
| 28 | usePlacement(app, { | |
| 29 | place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${plan.name}` }), | |
| 30 | retire: async () => ok(), | |
| 31 | }); | |
| 32 | }); | |
| 33 | afterAll(async () => { | |
| 34 | await servers.stop?.(); | |
| 35 | app.close(); | |
| 36 | await rm(dataDir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | const start = (name: string, extra: Record<string, string> = {}) => | |
| 40 | fetch(`${servers.url}/forges/start`, { | |
| 41 | method: "POST", | |
| 42 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...extra }, | |
| 43 | body: new URLSearchParams({ name }), | |
| 44 | redirect: "manual", | |
| 45 | }); | |
| 46 | const spent = (): number => (app.db.query("SELECT COUNT(*) AS n FROM anon_spawn").get() as { n: number }).n; | |
| 47 | ||
| 48 | test("only this boot's key is the prober: not the hook secret, not another boot's, not empty", () => { | |
| 49 | const req = (v?: string) => new Request("http://x/", v === undefined ? {} : { headers: { [PROBER_HEADER]: v } }); | |
| 50 | expect(isProber(app.hookSecret, req(proberKey(app.hookSecret)))).toBe(true); | |
| 51 | expect(isProber(app.hookSecret, req())).toBe(false); | |
| 52 | expect(isProber(app.hookSecret, req(""))).toBe(false); | |
| 53 | expect(isProber(app.hookSecret, req(app.hookSecret))).toBe(false); | |
| 54 | expect(isProber(app.hookSecret, req(proberKey("another-boot")))).toBe(false); | |
| 55 | expect(isProber("", req(proberKey("")))).toBe(false); | |
| 56 | expect(proberKey(app.hookSecret)).not.toContain(app.hookSecret); | |
| 57 | }); | |
| 58 | ||
| 59 | test("when the strangers' day is spent, the prober starts a forge and spends nothing; a stranger and a forger land on start=rate", async () => { | |
| 60 | resetAnonSpawns(app.db); | |
| 61 | for (let i = 0; i < ANON_SPAWNS_GLOBAL_PER_DAY; i++) | |
| 62 | noteAnonSpawn(app.db, new Request(`http://x/${i}`, { headers: { "x-forwarded-for": `10.${(i >> 8) & 255}.${i & 255}.1` } })); | |
| 63 | const before = spent(); | |
| 64 | ||
| 65 | for (const extra of [{}, { [PROBER_HEADER]: "0".repeat(64) }, { [PROBER_HEADER]: app.hookSecret }, { [PROBER_HEADER]: proberKey("forged") }]) { | |
| 66 | const res = await start("stranger", extra); | |
| 67 | expect(res.status).toBe(303); | |
| 68 | expect(res.headers.get("location") ?? "").toStartWith("/?start=rate"); | |
| 69 | } | |
| 70 | ||
| 71 | const res = await start("probe-own", { [PROBER_HEADER]: proberKey(app.hookSecret) }); | |
| 72 | expect(res.status).toBe(200); | |
| 73 | expect(await res.text()).toContain('id="made-open"'); | |
| 74 | expect(spent()).toBe(before); | |
| 75 | }); | |
| 76 | ||
| 77 | test("past the per-minute plan limit the prober still goes through, a forger does not", async () => { | |
| 78 | resetAnonSpawns(app.db); | |
| 79 | let limited = false; | |
| 80 | for (let i = 0; i < 40 && !limited; i++) { | |
| 81 | const res = await start(`burst-${i}`, { [PROBER_HEADER]: proberKey("forged") }); | |
| 82 | limited = res.status === 303 && (res.headers.get("location") ?? "").startsWith("/?start=rate"); | |
| 83 | } | |
| 84 | expect(limited).toBe(true); | |
| 85 | const res = await start("probe-two", { [PROBER_HEADER]: proberKey(app.hookSecret) }); | |
| 86 | expect(res.status).toBe(200); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the journey sends the key to its own host only, never to the new forge", async () => { | |
| 90 | const seen: { url: string; key: string | null }[] = []; | |
| 91 | const code = "a".repeat(43); | |
| 92 | const fake = (async (input: string | URL | Request, init?: RequestInit) => { | |
| 93 | const url = new URL(String(input)); | |
| 94 | seen.push({ url: url.href, key: new Headers(init?.headers).get(PROBER_HEADER) }); | |
| 95 | if (url.pathname === "/forges/start") { | |
| 96 | const name = new URLSearchParams(String(init?.body)).get("name"); | |
| 97 | return new Response(`<a id="made-open" href="https://${name}--kids.test/welcome#${code}">open</a>`, { status: 200 }); | |
| 98 | } | |
| 99 | if (url.pathname.endsWith("/ready")) return Response.json({ ready: true }); | |
| 100 | if (url.hostname === "host.test") return new Response("ok", { status: 200 }); | |
| 101 | return new Response("no", { status: 500 }); | |
| 102 | }) as typeof fetch; | |
| 103 | const walked = await walkJourney({ base: "https://host.test/", fetch: fake, proberKey: "k3y", pollMs: 0, sleep: async () => {} }); | |
| 104 | expect(walked.ok).toBe(false); | |
| 105 | const host = seen.filter((s) => new URL(s.url).hostname === "host.test"); | |
| 106 | const child = seen.filter((s) => new URL(s.url).hostname !== "host.test"); | |
| 107 | expect(host.some((s) => s.url.endsWith("/forges/start"))).toBe(true); | |
| 108 | expect(host.every((s) => s.key === "k3y")).toBe(true); | |
| 109 | expect(child.length).toBeGreaterThan(0); | |
| 110 | expect(child.every((s) => s.key === null)).toBe(true); | |
| 111 | }); |
new above
offline