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

During a rollout an app's host briefly serves forge pages (title forge, forge 404s) beside the app; app hosts must never fall through to the forge router #6729

Merged opened this in answer to

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

Merged

scout/forge:scout/6726-during-a-rollout-an-app-s-host-briefly-s → main 3 commits fork 8 threads · 8 unresolved

  • Merged. scout/6726-during-a-rollout-an-app-s-host-briefly-s was merged into main as 3ea5c60.

Review

Sign in to review this change.
9 files changed +263 −14
scripts/smoke.ts +5 −1
@@ -16,6 +16,8 @@
1616 * line per finding with its section (`--error-path` names a path that must answer
1717 * 500), and exits 0: it reports, it does not gate.
1818 *
19 * `--app-host <url>` also fetches `/` there and fails if it is the forge's page (`<title>forge</title>`).
20 *
1921 * `--outsider` also signs up a fresh account and proves each thing a
2022 * stranger must not do is refused over HTTP and MCP (`release/outsider.ts`,
2123 * #663); the terminal door is proven by the gate. It makes one real account, so run it against a forge whose signup is open to it, with
@@ -42,12 +44,13 @@ if (import.meta.main) {
4244 const standards = args.includes("--standards");
4345 if (standards) args.splice(args.indexOf("--standards"), 1);
4446 const errorPath = flag("--error-path");
47 const appHost = flag("--app-host");
4548 const version = flag("--version");
4649 const wait = flag("--wait");
4750 const base = args[0];
4851 const seconds = wait === undefined ? 120 : Number(wait);
4952 if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) {
50 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)");
53 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--app-host <url of an app>] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)");
5154 process.exit(2);
5255 }
5356 if (withOutsider && !process.env["FORGE_TOKEN"]) {
@@ -69,6 +72,7 @@ if (import.meta.main) {
6972 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", {
7073 version,
7174 waitMs: seconds * 1000,
75 ...(appHost ? { appHost } : {}),
7276 // The forge tells only its probe its version (`src/kernel/probe.ts`).
7377 ...(process.env["FORGE_PROBE"] ? { probe: { [PROBE_HEADER]: process.env["FORGE_PROBE"] } } : {}),
7478 });
src/apps/holding.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * An app host is never the forge's. The listener opens before the apps

Low: because the hold is never removed, this rule also covers the old pod after AppsRuntime.stop() deletes its runtime while the listener still drains. That window is likely the minutes-long one in #6726. Worth saying so here, and worth a test that does stop() → 503, so a later 'clear the hold on release' refactor doesn't quietly reopen it.

3 * runtime is registered, and in that window a request for
4 * `hello.<domain>` has no runtime to resolve against and would fall through
5 * to the forge's own router (#6726). While a forge holds its app hosts, a
6 * request for one waits briefly for the load, then gets a small page with
7 * `Retry-After`: never a forge page.
8 */
9
10 import type { App } from "../app.ts";
11 import { appsRuntime } from "./runtime.ts";
12 import { parseAppHost } from "./proxy.ts";
13
14 /** How long a request waits for the apps to load before the holding page. */
15 export const HOLD_WAIT_MS = 2000;
16
17 interface Hold {
18 domain: string;
19 release: () => void;
20 released: Promise<void>;
21 }
22
23 const holds = new WeakMap<App, Hold>();
24
25 /**
26 * The domain this forge's app hosts live under, kept from boot so a request
27 * can be told apart before any runtime is registered.
28 */
29 export function holdAppHosts(app: App, domain: string): void {
30 let release!: () => void;
31 const released = new Promise<void>((resolve) => (release = resolve));
32 holds.set(app, { domain, release, released });
33 }
34
35 /** The runtime is registered: waiting requests go on to the proxy. */
36 export function releaseAppHosts(app: App): void {
37 holds.get(app)?.release();
38 }
39
40 const PAGE =
41 '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
42 "<title>Coming back</title><body><p>This app is coming back. Try again in a moment.</p>";
43
44 /**
45 * `undefined` when the request is not for an app host of this forge, or a
46 * runtime is registered; the holding page otherwise. A request for an app
47 * host never reaches the forge's router: with no runtime it waits (at most
48 * `waitMs`) for one to be registered, then gets a 503.
49 */
50 export async function holdAppHost(
51 app: App,
52 request: Request,
53 waitMs = HOLD_WAIT_MS,
54 ): Promise<Response | undefined> {
55 if (appsRuntime(app) !== undefined) return undefined;
56 const hold = holds.get(app);
57 if (hold === undefined) return undefined;
58 const host = request.headers.get("host");
59 if (host === null || parseAppHost(host, hold.domain) === undefined)
60 return undefined;
61 let timer: ReturnType<typeof setTimeout> | undefined;
62 const timeout = new Promise<void>((resolve) => {
63 timer = setTimeout(resolve, waitMs);
64 });
65 await Promise.race([hold.released, timeout]);
66 clearTimeout(timer);
67 if (appsRuntime(app) !== undefined) return undefined;
68 return new Response(request.method === "HEAD" ? null : PAGE, {
69 status: 503,
70 headers: {
71 "content-type": "text/html; charset=utf-8",
72 "retry-after": "2",
73 "cache-control": "no-store",
74 },
75 });
76 }
src/http/server.ts +3
@@ -539,6 +539,7 @@ import { sseStream, viewerStream } from "./sse.ts";
539539 import { Router } from "./router.ts";
540540 import { getUserById } from "../core/users.ts";
541541 import { appsRuntime } from "../apps/runtime.ts";
542 import { holdAppHost } from "../apps/holding.ts";
542543 import {
543544 appWebSocket,
544545 type AppSocket,
@@ -2270,6 +2271,8 @@ export async function serve(app: App): Promise<Servers> {
22702271 noteStart(request, started);
22712272 // An app's hostname is answered before any route: `hello.<domain>` is
22722273 // the app, whatever its path, and the forge's own routes never apply.
2274 const holding = await holdAppHost(app, request);

This only covers requests while a hold is set. Once it is lifted, or when it was never set, an app-shaped Host with runtime === undefined still reaches the forge router below. That is the bug in #6726. Make the rule unconditional: a Host under the boot-resolved app domain with no runtime gets the 503 holding page.

2275 if (holding !== undefined) return securityFloor(holding);
22732276 const runtime = appsRuntime(app);
22742277 // The parent's own app host wins: a child named like a pull number
22752278 // must not take `<app>--<n>--<parentHost>`. Only its `"unknown"` falls
src/main.ts +29 −12
@@ -31,6 +31,8 @@ import { checkDue } from "./actions/upstream_follow.ts";
3131 import { runSync } from "./origins/sync.ts";
3232 import { startForgeSsh } from "./tui/ssh.ts";
3333 import { AppsRuntime } from "./apps/runtime.ts";
34 import { listApps } from "./core/apps.ts";
35 import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts";
3436 import { resolveAppDomain } from "./apps/host.ts";
3537 import { LAND_SETTLE_MS, requeueAbandonedChecks, startChecks } from "./checks/service.ts";
3638 import { RED_HOLD_MS } from "./checks/red_main.ts";
@@ -280,6 +282,21 @@ await reconcile(app);
280282 // Issues linked under a pillar's umbrella before pillars had labels.
281283 const labelled = syncAllPillars(app);
282284 if (labelled > 0) app.log.info("pillars labelled", { issues: labelled });
285 // The host apps answer at, resolved once and before the listener opens: an
286 // app host never reaches the forge's own router, so `serve` needs it from the
287 // first connection (#6726). Only the hostname matters here, not the port.
288 const appsOn = process.env.FORGE_APPS !== "0";
outdated

This is a second copy of the appDomain resolution further down, built from different inputs: here app.config.host with no port, there publicUrl. If the two disagree, the forge holds one domain and serves another. Resolve it once, before serve(), and use that value in both places.

289 const appDomain = appsOn
290 ? resolveAppDomain({
291 explicit: (process.env.FORGE_APP_DOMAIN ?? "").trim() || undefined,
292 hosted,
293 hostname: new URL(
294 (hosted ? hostedAt : process.env.FORGE_URL)?.trim().replace(/\/+$/, "") ||
295 `http://${app.config.host ?? "localhost"}`,
296 ).hostname,
297 })
298 : undefined;
299 if (appDomain?.ok === true) holdAppHosts(app, appDomain.domain);
283300 const servers = await serve(app);
284301 const sshStarted =
285302 sshPort > 0 ? await startForgeSsh(app, { port: sshPort }) : undefined;
@@ -300,18 +317,10 @@ const forgeUrl =
300317 (hosted ? hostedAt : process.env.FORGE_URL)?.replace(/\/+$/, "") ??
301318 `http://${host}:${servers.port}`;
302319 const publicUrl = new URL(forgeUrl);
303 const appsOn = process.env.FORGE_APPS !== "0";
304 // The host apps answer at is checked once, here: an IP-addressed forge
305 // hands out a hostname no URL can hold once a slug attaches to it, and an
306 // app that parses its own FORGE_APP_URL (the scaffold does) dies before it
307 // answers — every deploy, forever. docs/APPS.md, "Hostnames and the proxy".
308 const appDomain = appsOn
309 ? resolveAppDomain({
310 explicit: (process.env.FORGE_APP_DOMAIN ?? "").trim() || undefined,
311 hosted,
312 hostname: publicUrl.hostname,
313 })
314 : undefined;
320 // An IP-addressed forge hands out a hostname no URL can hold once a slug
321 // attaches to it, and an app that parses its own FORGE_APP_URL (the scaffold
322 // does) dies before it answers — every deploy, forever. docs/APPS.md,
323 // "Hostnames and the proxy".
315324 if (appDomain?.ok === false) app.log.error(appDomain.reason);
316325 if (appDomain?.ok === true && appDomain.note !== undefined)
317326 app.log.warn(appDomain.note);
@@ -338,6 +347,9 @@ const apps = appDomain?.ok
338347 : undefined;
339348 // Which other forge serves this data's apps, read before this one starts its own.
340349 const presence = apps ? takePresence(app.config.dataDir) : undefined;
350 // The runtime is registered by its constructor: app hosts stop waiting now,
351 // not at the end of `start()`.
352 releaseAppHosts(app);
341353 await apps?.start();
342354 // In a rollout this forge takes no traffic until the apps it just started
343355 // serve: the one it replaces keeps proxying to its own copies until then,
@@ -544,6 +556,11 @@ void proveRelease({
544556 version: running().version,
545557 url: forgeUrl,
546558 token: process.env["FORGE_TOKEN"],
559 // The first app's production host: it must never answer as the forge (#6726).
560 appHost: (() => {
561 const first = listApps(app.db)[0];
562 return apps && first ? apps.urlFor(first, "production") : undefined;
563 })(),
547564 }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) }));
548565 // The release tags of the forge's own repository are its shipped releases (release/shipped.ts);
549566 // production behind the newest for over an hour is a card (release/behind.ts).
src/release/prove.ts +3 −1
@@ -66,6 +66,8 @@ export interface ProveOptions {
6666 readonly url: string;
6767 /** Signs `/board` in; without it a redirect to sign-in passes. */
6868 readonly token?: string | undefined;
69 /** An app's URL on this forge: its `/` must never be the forge's page (#6726). */
70 readonly appHost?: string | undefined;
6971 readonly fetch?: typeof fetch;
7072 readonly now?: () => number;
7173 readonly sleep?: (ms: number) => Promise<void>;
@@ -102,7 +104,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
102104 if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`);
103105 await sleep(POLL_MS);
104106 }
105 const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true });
107 const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true, ...(o.appHost ? { appHost: o.appHost } : {}) });
106108 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]));
107109 } catch (error) {
108110 return finish(`the smoke checks could not run: ${String(error)}`);
src/release/smoke.ts +12
@@ -59,6 +59,8 @@ export interface Options {
5959 fetch?: typeof fetch;
6060 /** Accept a redirect from `/board` to sign-in as the sign-in it is, for a caller holding no token. */
6161 anonymousBoard?: boolean;
62 /** An app's URL on this forge: `GET /` there must never be a forge page (#6726). */
63 appHost?: string;
6264 }
6365
6466 /** The redirect statuses a sign-in answers with. */
@@ -169,5 +171,15 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr
169171 else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()");
170172 }
171173 }
174 if (options.appHost) {

Nothing in the release path sets appHost: src/release/prove.ts:105 calls smoke(o.url, …) without it. Wire an app URL in there so the front-door check actually runs this probe on every rollout (#6726 done-when #2).

175 const url = new URL("/", options.appHost).href;
176 try {
177 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(10_000), headers: { "user-agent": AGENTS["desktop"]! } });
178 const body = await res.text();
179 if (/<title>\s*forge\s*<\/title>/i.test(body)) fail(url, "desktop", res, "an app host answered with the forge's page");
180 } catch (error) {
181 failures.push({ url, agent: "desktop", status: "error", contentType: "", why: String(error) });
182 }
183 }
172184 return failures;
173185 }
test/app-host-holding.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * While the apps table is not loaded, an app's host is never the forge's
3 * (#6726): it waits, then gets a 503 holding page with Retry-After; once
4 * released, the host goes on to the app.
5 */
6
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { newApp } from "../src/actions/apps.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { holdAppHost, holdAppHosts, releaseAppHosts } from "../src/apps/holding.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-holding-"));
27 app = createApp({ dataDir, port: 0, host: "forge.test" });
28 servers = await serve(app);
29 });
30 afterAll(async () => {
31 servers.stop();
32 app.close();
33 await rm(dataDir, { recursive: true, force: true });
34 });
35
36 const get = (host: string) => fetch(`${servers.url}/`, { headers: { host } });
37
38 test("with no runtime, an app host is the holding page — held or not, never the forge", async () => {
39 holdAppHosts(app, "apps.test");
40 const waiting = await holdAppHost(app, new Request("http://x/", { headers: { host: "notes.apps.test" } }), 20);
41 expect(waiting?.status).toBe(503);
42 expect(waiting?.headers.get("retry-after")).toBe("2");
43 expect(await waiting?.text()).not.toContain("<title>forge</title>");
44
45 // Released with no runtime (start threw, say): still the holding page.
46 releaseAppHosts(app);
47 const res = await get("notes.apps.test");
48 expect(res.status).toBe(503);
49 expect(res.headers.get("retry-after")).toBe("2");
50 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
51 expect(await res.text()).not.toContain("<title>forge</title>");
outdated

With no runtime, this response is the forge's own page, so the test passes while the bug is still there. Assert what the issue asks for: after release, an app host is never <title>forge</title>. Also add a case with a registered runtime that shows the request reaches the app (or unknown → 404).

52 });
53
54 test("the forge's own host is the forge's", async () => {
55 const own = await get("forge.test");
56 expect(own.status).toBe(200);
57 expect(await own.text()).toContain("<title>");
58 });
59
60 test("a request waiting for the runtime goes on once it is registered", async () => {
61 const fresh = createApp({ dataDir: join(dataDir, "fresh"), port: 0, host: "forge.test" });
62 holdAppHosts(fresh, "apps.test");
63 const waiting = holdAppHost(fresh, new Request("http://x/", { headers: { host: "notes.apps.test" } }), 1500);
64 new AppsRuntime(fresh, { domain: "apps.test", scheme: "http", publicPort: 80, forgeUrl: "http://forge.test", concurrency: 1 });
65 releaseAppHosts(fresh);
66 expect(await waiting).toBeUndefined();
67 fresh.close();
68 });
outdated

Done-when #3 asks for the app's page once loaded, and this only checks an unknown name. Register a runtime with an app that resolves, the way the proxy tests stub a running app, and assert its body comes through on its host.

69
70 test("the app's page once loaded: a request that waited is answered by the app side, not the forge", async () => {
71 const dir = await mkdtemp(join(tmpdir(), "forge-holding-app-"));
72 const fresh = createApp({ dataDir: dir, port: 0, host: "forge.test" });
73 holdAppHosts(fresh, "apps.test");
74 const up = await serve(fresh);
75 const u = await createUser(fresh.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
outdated

This runs on app, where the previous test already registered an AppsRuntime. holdAppHost returns undefined at if (appsRuntime(app) !== undefined) before reading Host, so this would pass even if parseAppHost were removed. Use a fresh createApp with no runtime, as the waiting test does. Assert those hosts get undefined while notes.apps.test on the same app gets 503.

76 if (!u.ok) throw new Error(u.error);
77 const made = await newApp(fresh, u.value, { name: "notes", description: "x", visibility: "public" });
78 if (!made.ok) throw new Error(String(made.error));
79 try {
80 const pending = fetch(`${up.url}/__forge/`, { headers: { host: "notes.apps.test" } });
81 await Bun.sleep(30);
82 new AppsRuntime(fresh, { domain: "apps.test", scheme: "http", publicPort: up.port, forgeUrl: up.url, concurrency: 1 });
83 releaseAppHosts(fresh);
84 const res = await pending;
85 expect(res.status).toBe(200);
86 const html = await res.text();
87 expect(html).toContain("<h1>notes</h1>");
88 expect(html).not.toContain("<title>forge</title>");
89 // An unknown name is the app side's 404, never the forge's.
90 const none = await fetch(`${up.url}/`, { headers: { host: "nothing.apps.test" } });
91 expect(none.status).toBe(404);
92 expect(await none.text()).not.toContain("<title>forge</title>");
93 } finally {
94 up.stop();
95 fresh.close();
96 await rm(dir, { recursive: true, force: true });
97 }
98 }, 30_000);
99
100 test("only app hosts are held: forge-shaped and unrelated hosts are not, on an app with no runtime", async () => {
101 const fresh = createApp({ dataDir: join(dataDir, "bare"), port: 0, host: "forge.test" });
102 holdAppHosts(fresh, "apps.test");
103 const ask = (host: string) => holdAppHost(fresh, new Request("http://x/", { headers: { host } }), 10);
104 for (const host of ["forge.test", "a.b.apps.test", "elsewhere.example"])
105 expect(await ask(host)).toBeUndefined();
106 expect((await ask("notes.apps.test"))?.status).toBe(503);
107 fresh.close();
108 });
test/release-prove.test.ts +13
@@ -51,6 +51,19 @@ describe("proving a release", () => {
5151 expect(proofNeed(dir)).toBeUndefined();
5252 });
5353
54 test("an app host that answers as the forge fails the proof (#6726)", async () => {
55 const { fake: base } = site("1.2.6");
56 const fake = (async (input: string | URL, init?: RequestInit) =>
57 new URL(String(input)).host === "notes.apps.test"
58 ? new Response("<title>forge</title>", { headers: { "content-type": "text/html" } })
59 : base(input, init)) as unknown as typeof fetch;
60 const p = await run("1.2.6", fake, { appHost: "https://notes.apps.test" });
61 expect(p?.ok).toBe(false);
62 expect(p?.failure).toContain("an app host answered with the forge's page");
63 const good = await run("1.2.7", site("1.2.7").fake, { appHost: "https://notes.apps.test" });
64 expect(good?.ok).toBe(true);
65 });
66
5467 test("a failure raises the cause once, naming the first failing check", async () => {
5568 const { fake } = site("1.2.4", { "/a.css": () => new Response("x", { status: 500 }) });
5669 await run("1.2.4", fake);
test/smoke.test.ts +14
@@ -160,3 +160,17 @@ test("--outsider without FORGE_TOKEN exits 2 and prints no ok", async () => {
160160 expect(await new Response(proc.stdout).text()).not.toContain("ok");
161161 expect(await new Response(proc.stderr).text()).toContain("FORGE_TOKEN");
162162 });
163
164 test("--app-host fails when an app host answers with the forge's page", async () => {
165 const forgePage = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<title>forge</title>", { headers: { "content-type": "text/html" } }) });
166 const appPage = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<title>notes</title>") });
167 try {
168 const base = stub("text/css");
169 expect(await smoke(base, "t", { appHost: `http://127.0.0.1:${appPage.port}` })).toEqual([]);
170 const failures = await smoke(base, "t", { appHost: `http://127.0.0.1:${forgePage.port}` });
171 expect(failures.map((f) => f.why)).toEqual(["an app host answered with the forge's page"]);
172 } finally {
173 forgePage.stop(true);
174 appPage.stop(true);
175 }
176 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs