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

An app host's /__forge/ door shows what is serving: the version, since when, and the last three releases #5995

Merged opened this

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

Merged

scout/forge:scout/5978-an-app-host-s-forge-door-shows-what-is-s → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/5978-an-app-host-s-forge-door-shows-what-is-s was merged into main as b876305.

Review

Sign in to review this change.
6 files changed +178 −4
README.md +6 −1
@@ -1137,7 +1137,12 @@ scripts), `/__forge/` on the app's host is the way back: a small page from the
11371137 forge, never the app, with no script, saying the app and where you are
11381138 (`production` or `preview of pull #N`) and linking Home, the repository (to
11391139 who may read it) and *Tell the crew* (the same link `/__forge/crew` gives; signed
1140 out it offers sign in). The prefix was already the forge's, so an app's own
1140 out it offers sign in). It also says what is serving, read from the deploy
1141 record (no git): production shows the newest release and the three before it
1142 (a redeploy of the same commit is not another release), a preview shows its
1143 pull and the commit it last served, or "not deployed yet". A viewer who cannot
1144 read the repository sees only the time, never a sha. The read is the `serving`
1145 Server-Timing phase. The prefix was already the forge's, so an app's own
11411146 `/__forge/` path is not reachable.
11421147
11431148 **Tell the crew from the pill.** Signed in as someone who may direct the crew,
src/apps/door.ts +39 −1
@@ -6,6 +6,7 @@
66
77 import { createHash } from "node:crypto";
88 import { escapeAttr, type PillWhere } from "./toolbar.ts";
9 import type { Served, Serving } from "./serving.ts";
910
1011 export type DoorFacts = {
1112 where: PillWhere;
@@ -14,6 +15,8 @@ export type DoorFacts = {
1415 tell?: string;
1516 /** This host's own sign-in door, offered when nobody is signed in. */
1617 signin?: string;
18 /** What the host is serving, read from the deploy record. */
19 serving?: Serving | undefined;
1720 /**
1821 * The terminal, only for someone who may write the repository: the exact
1922 * `ssh …` command, or null when the terminal is off. Unset shows nothing.
@@ -37,6 +40,41 @@ export function doorWhere(where: PillWhere): string {
3740 const link = (href: string, text: string): string =>
3841 `<li><a href="${escapeAttr(href)}">${escapeAttr(text)}</a></li>`;
3942
43 /** `2026-10-25 14:03 UTC`, or the raw text when it is not a date. */
44 function when(at: string): string {
45 const t = Date.parse(at);
46 return Number.isNaN(t) ? at : new Date(t).toISOString().slice(0, 16).replace("T", " ") + " UTC";
47 }
48
49 const stamp = (at: string): string =>
50 `<time datetime="${escapeAttr(at)}">${escapeAttr(when(at))}</time>`;
51
52 function servingHtml(facts: DoorFacts): string {
53 const s = facts.serving;
54 if (s === undefined) return "";
55 const repo = facts.where.repo;
outdated

High: when repo is undefined the viewer may not read the repository, but its commit sha (and on production, four releases with dates) is still printed. Show no sha, or no block, without repo, and test the guest case.

56 // Commit ids and the release history belong to someone who may read the
57 // repository; a guest of the app learns only when it last changed.
58 if (repo === undefined) {
59 if (s.kind === "preview")
60 return `<p>Preview of #${s.pull}${
61 s.current === undefined ? ", not deployed yet" : `, deployed ${stamp(s.current.at)}`
62 }</p>`;
63 const now = s.releases[0];
64 return now === undefined ? "<p>not released yet</p>" : `<p>Serving since ${stamp(now.at)}</p>`;
65 }
66 const sha = (x: Served): string =>
67 `<a href="${escapeAttr(new URL(`/${repo}/commit/${x.sha}`, facts.forgeUrl).href)}"><code>${escapeAttr(x.sha.slice(0, 7))}</code></a>`;
68 if (s.kind === "preview")
69 return `<p>Preview of #${s.pull}${
70 s.current === undefined ? ", not deployed yet" : ` at ${sha(s.current)}, deployed ${stamp(s.current.at)}`
71 }</p>`;
72 const [now, ...before] = s.releases;
73 if (now === undefined) return "<p>not released yet</p>";
74 const older = before.map((r) => `<li>${sha(r)}, ${stamp(r.at)}</li>`).join("");
75 return `<p>Serving ${sha(now)} since ${stamp(now.at)}</p>${older === "" ? "" : `<h2>Before it</h2><ul>${older}</ul>`}`;
76 }
77
4078 export function doorPage(facts: DoorFacts, head: boolean): Response {
4179 const { where, forgeUrl } = facts;
4280 const links = [link(forgeUrl, "Home")];
@@ -55,7 +93,7 @@ export function doorPage(facts: DoorFacts, head: boolean): Response {
5593 : `<p>Terminal:</p><pre><code>${escapeAttr(facts.ssh)}</code></pre>`;
5694 const html = `<!doctype html>
5795 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escapeAttr(where.app)}, ${escapeAttr(doorWhere(where))}</title><style>${STYLE}</style></head>
58 <body><main><h1>${escapeAttr(where.app)}</h1><p>${escapeAttr(doorWhere(where))}</p><ul>${links.join("")}</ul>${terminal}</main></body></html>
96 <body><main><h1>${escapeAttr(where.app)}</h1><p>${escapeAttr(doorWhere(where))}</p>${servingHtml(facts)}<ul>${links.join("")}</ul>${terminal}</main></body></html>
5997 `;
6098 return new Response(head ? null : html, {
6199 headers: {
src/apps/proxy.ts +5
@@ -41,6 +41,7 @@ import { feedback, mintProof } from "./feedback.ts";
4141 import { clientBucket } from "../http/peer.ts";
4242 import { doorPage } from "./door.ts";
4343 import { takePhases, timePhase } from "../core/route_timing.ts";
44 import { readServing } from "./serving.ts";
4445 import { notFound, securityFloor } from "../http/respond.ts";
4546 import { appMeasure } from "./measure.ts";
4647 import { holdPortRequest, holdPortSocket } from "./handoff.ts";
@@ -681,10 +682,14 @@ export async function proxyApp(
681682 const where = timePhase(request, "pull", () =>
682683 pillWhere(runtime, target, signed?.who),
683684 );
685 const serving = timePhase(request, "serving", () =>
686 readServing(runtime.app.db, target.app.repoId, target.env),
687 );
684688 const page = timePhase(request, "render", () =>
685689 doorPage(
686690 {
687691 where,
692 ...(serving === undefined ? {} : { serving }),
688693 forgeUrl: runtime.options.forgeUrl,
689694 ...(ssh === undefined ? {} : { ssh }),
690695 ...(tell === undefined ? { signin: "/__forge/signin" } : { tell }),
src/apps/serving.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * What an app's host is serving: read from the forge's own deploy record,
3 * never from git. Production names the newest release and the three before
4 * it; a preview names the commit it last served. Plain data in, plain data out.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export type Served = { sha: string; at: string };
10
11 export type Serving =
12 | { kind: "production"; releases: Served[] }
13 | { kind: "preview"; pull: number; current?: Served };
14
15 /** The newest release and the three before it. */
16 export const SHOWN = 4;
17
18 /** Each commit once, at its latest deploy: a redeploy is not another release. */
19 function deploys(db: Database, repoId: string, env: string, limit: number): Served[] {
20 return db
21 .query<Served, [string, string, number]>(
22 `SELECT commit_oid AS sha, MAX(serving_at) AS at FROM run
23 WHERE repo_id = ?1 AND kind = 'deploy' AND env = ?2
24 AND serving_at IS NOT NULL AND commit_oid <> ''
25 GROUP BY commit_oid

Sorting by MAX(serving_at) has no matching index; fine at today sizes, revisit when a long-lived app has thousands of deploys.

26 ORDER BY at DESC, MAX(id) DESC LIMIT ?3`,
27 )
28 .all(repoId, env, limit);
29 }
30
31 export function readServing(db: Database, repoId: string, env: string): Serving | undefined {
32 if (env === "production")
33 return { kind: "production", releases: deploys(db, repoId, env, SHOWN) };
34 const pull = /^pull-(\d+)$/.exec(env)?.[1];
35 if (pull === undefined) return undefined;
36 const current = deploys(db, repoId, env, 1)[0];
37 return { kind: "preview", pull: Number(pull), ...(current === undefined ? {} : { current }) };
38 }
test/apps-door-serving.test.ts added +88
@@ -0,0 +1,88 @@
1 import { Database } from "bun:sqlite";
2 import { expect, test } from "bun:test";
3 import { doorPage } from "../src/apps/door.ts";
4 import { readServing } from "../src/apps/serving.ts";
5
6 const db = new Database(":memory:");
7 db.run(`CREATE TABLE run (id INTEGER PRIMARY KEY, repo_id TEXT, kind TEXT, env TEXT, commit_oid TEXT, serving_at TEXT)`);
8 const deploy = (env: string, sha: string, at: string | null) =>
9 db.run(`INSERT INTO run (repo_id, kind, env, commit_oid, serving_at) VALUES ('r1','deploy',?,?,?)`, [env, sha, at]);
10
11 const page = async (repoId: string, env: string) => {
12 const where = { app: "notes", env, repo: "me/notes", ...(env === "production" ? {} : { pull: 7 }) };
13 const res = doorPage(
14 { where, forgeUrl: "https://forge.test", serving: readServing(db, repoId, env) },
15 false,
16 );
17 return { res, html: await res.text() };
18 };
19
20 test("production shows the newest release and the three before it, linked", async () => {
21 for (const [i, c] of ["a", "b", "c", "d", "e"].entries())
22 deploy("production", c.repeat(40), `2026-10-0${i + 1}T10:00:00.000Z`);
23 deploy("production", "f".repeat(40), null);
24 const { html } = await page("r1", "production");
25 expect(html).toContain("Serving <a href=\"https://forge.test/me/notes/commit/" + "e".repeat(40));
26 expect(html).toContain("since <time datetime=\"2026-10-05T10:00:00.000Z\">2026-10-05 10:00 UTC");
27 for (const c of ["d", "c", "b"]) expect(html).toContain(`/commit/${c.repeat(40)}`);
28 expect(html).not.toContain("a".repeat(40));
29 expect(html).not.toContain("f".repeat(7));
30 });
31
32 test("a preview shows its pull and short sha", async () => {
33 deploy("pull-7", "1234567890abcdef".repeat(2) + "12345678", "2026-10-06T08:30:00.000Z");
34 const { html } = await page("r1", "pull-7");
35 expect(html).toContain("Preview of #7 at ");
36 expect(html).toContain("<code>1234567</code>");
37 expect(html).toContain(", deployed <time");
38 expect(html).not.toContain("Serving");
39 });
40
41 test("an app never released says so", async () => {
42 const { html } = await page("none", "production");
43 expect(html).toContain("not released yet");
44 });
45
46 test("a guest, whose page names no repository, sees no sha and no history", async () => {
47 const guest = (where: { app: string; env: string; pull?: number }, serving = readServing(db, "r1", where.env)) =>
48 doorPage({ where, forgeUrl: "https://forge.test", serving }, false).text();
49 const prod = await guest({ app: "notes", env: "production" });
50 expect(prod).toContain("Serving since <time");
51 expect(prod).not.toMatch(/<code>|\/commit\/|Before it/);
52 for (const c of "abcde") expect(prod).not.toContain(c.repeat(7));
53 const prev = await guest({ app: "notes", env: "pull-7", pull: 7 });
54 expect(prev).toContain("Preview of #7, deployed <time");
55 expect(prev).not.toMatch(/<code>|1234567/);
56 });
57
58 test("a commit deployed twice is one release, and the fourth older one still shows", async () => {
59 const d = new Database(":memory:");
60 d.run(`CREATE TABLE run (id INTEGER PRIMARY KEY, repo_id TEXT, kind TEXT, env TEXT, commit_oid TEXT, serving_at TEXT)`);
61 const put = (sha: string, at: string) =>
62 d.run(`INSERT INTO run (repo_id, kind, env, commit_oid, serving_at) VALUES ('r2','deploy','production',?,?)`, [sha, at]);
63 put("a".repeat(40), "2026-10-01T10:00:00.000Z");
64 put("b".repeat(40), "2026-10-02T10:00:00.000Z");
65 put("c".repeat(40), "2026-10-03T10:00:00.000Z");
66 put("d".repeat(40), "2026-10-04T10:00:00.000Z");
67 put("d".repeat(40), "2026-10-05T10:00:00.000Z");
68 const s = readServing(d, "r2", "production");
69 expect(s).toEqual({
70 kind: "production",
71 releases: [
72 { sha: "d".repeat(40), at: "2026-10-05T10:00:00.000Z" },
73 { sha: "c".repeat(40), at: "2026-10-03T10:00:00.000Z" },
74 { sha: "b".repeat(40), at: "2026-10-02T10:00:00.000Z" },
75 { sha: "a".repeat(40), at: "2026-10-01T10:00:00.000Z" },
76 ],
77 });
78 });
79
80 test("a preview with no deploy says so", async () => {
81 const s = readServing(db, "r1", "pull-99");
82 expect(s).toEqual({ kind: "preview", pull: 99 });
83 const html = await doorPage(
84 { where: { app: "notes", env: "pull-99", repo: "me/notes", pull: 99 }, forgeUrl: "https://forge.test", serving: s },
85 false,
86 ).text();
87 expect(html).toContain("Preview of #99, not deployed yet");
88 });
test/apps-door.test.ts +2 −2
@@ -260,7 +260,7 @@ test("the door and the crew line name the phases they read, signed in, on produc
260260 [`notes--${pull}.${DOMAIN}`, grantFor(`pull-${pull}`)],
261261 ];
262262 const reads: Record<string, string[]> = {
263 "/__forge/": ["session", "app", "pull", "render"],
263 "/__forge/": ["session", "app", "pull", "serving", "render"],
264264 "/__forge/crew": ["session", "app", "pull", "working", "render"],
265265 };
266266 for (const [host, cookie] of hosts)
@@ -279,6 +279,6 @@ test("a guest names session and render, and the crew line stops before the reads
279279 for (const ms of crew.values()) expect(Number.isFinite(ms)).toBe(true);
280280 // The door still looks the app and pull up for its words.
281281 const page = await phasesOf(host, "/__forge/");
282 expect([...page.keys()].sort()).toEqual(["app", "pull", "render", "session"]);
282 expect([...page.keys()].sort()).toEqual(["app", "pull", "render", "serving", "session"]);
283283 }
284284 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs