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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5978-an-app-host-s-forge-door-shows-what-is-s → main 6 commits fork 4 threads · 4 unresolved
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 | ||
| 1137 | 1137 | forge, never the app, with no script, saying the app and where you are |
| 1138 | 1138 | (`production` or `preview of pull #N`) and linking Home, the repository (to |
| 1139 | 1139 | 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 | |
| 1141 | 1146 | `/__forge/` path is not reachable. |
| 1142 | 1147 | |
| 1143 | 1148 | **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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { createHash } from "node:crypto"; |
| 8 | 8 | import { escapeAttr, type PillWhere } from "./toolbar.ts"; |
| 9 | import type { Served, Serving } from "./serving.ts"; | |
| 9 | 10 | |
| 10 | 11 | export type DoorFacts = { |
| 11 | 12 | where: PillWhere; |
| @@ -14,6 +15,8 @@ export type DoorFacts = { | ||
| 14 | 15 | tell?: string; |
| 15 | 16 | /** This host's own sign-in door, offered when nobody is signed in. */ |
| 16 | 17 | signin?: string; |
| 18 | /** What the host is serving, read from the deploy record. */ | |
| 19 | serving?: Serving | undefined; | |
| 17 | 20 | /** |
| 18 | 21 | * The terminal, only for someone who may write the repository: the exact |
| 19 | 22 | * `ssh …` command, or null when the terminal is off. Unset shows nothing. |
| @@ -37,6 +40,41 @@ export function doorWhere(where: PillWhere): string { | ||
| 37 | 40 | const link = (href: string, text: string): string => |
| 38 | 41 | `<li><a href="${escapeAttr(href)}">${escapeAttr(text)}</a></li>`; |
| 39 | 42 | |
| 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; | |
| 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 | ||
| 40 | 78 | export function doorPage(facts: DoorFacts, head: boolean): Response { |
| 41 | 79 | const { where, forgeUrl } = facts; |
| 42 | 80 | const links = [link(forgeUrl, "Home")]; |
| @@ -55,7 +93,7 @@ export function doorPage(facts: DoorFacts, head: boolean): Response { | ||
| 55 | 93 | : `<p>Terminal:</p><pre><code>${escapeAttr(facts.ssh)}</code></pre>`; |
| 56 | 94 | const html = `<!doctype html> |
| 57 | 95 | <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> | |
| 59 | 97 | `; |
| 60 | 98 | return new Response(head ? null : html, { |
| 61 | 99 | headers: { |
src/apps/proxy.ts +5
| @@ -41,6 +41,7 @@ import { feedback, mintProof } from "./feedback.ts"; | ||
| 41 | 41 | import { clientBucket } from "../http/peer.ts"; |
| 42 | 42 | import { doorPage } from "./door.ts"; |
| 43 | 43 | import { takePhases, timePhase } from "../core/route_timing.ts"; |
| 44 | import { readServing } from "./serving.ts"; | |
| 44 | 45 | import { notFound, securityFloor } from "../http/respond.ts"; |
| 45 | 46 | import { appMeasure } from "./measure.ts"; |
| 46 | 47 | import { holdPortRequest, holdPortSocket } from "./handoff.ts"; |
| @@ -681,10 +682,14 @@ export async function proxyApp( | ||
| 681 | 682 | const where = timePhase(request, "pull", () => |
| 682 | 683 | pillWhere(runtime, target, signed?.who), |
| 683 | 684 | ); |
| 685 | const serving = timePhase(request, "serving", () => | |
| 686 | readServing(runtime.app.db, target.app.repoId, target.env), | |
| 687 | ); | |
| 684 | 688 | const page = timePhase(request, "render", () => |
| 685 | 689 | doorPage( |
| 686 | 690 | { |
| 687 | 691 | where, |
| 692 | ...(serving === undefined ? {} : { serving }), | |
| 688 | 693 | forgeUrl: runtime.options.forgeUrl, |
| 689 | 694 | ...(ssh === undefined ? {} : { ssh }), |
| 690 | 695 | ...(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 | ||
| 260 | 260 | [`notes--${pull}.${DOMAIN}`, grantFor(`pull-${pull}`)], |
| 261 | 261 | ]; |
| 262 | 262 | const reads: Record<string, string[]> = { |
| 263 | "/__forge/": ["session", "app", "pull", "render"], | |
| 263 | "/__forge/": ["session", "app", "pull", "serving", "render"], | |
| 264 | 264 | "/__forge/crew": ["session", "app", "pull", "working", "render"], |
| 265 | 265 | }; |
| 266 | 266 | 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 | ||
| 279 | 279 | for (const ms of crew.values()) expect(Number.isFinite(ms)).toBe(true); |
| 280 | 280 | // The door still looks the app and pull up for its words. |
| 281 | 281 | 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"]); | |
| 283 | 283 | } |
| 284 | 284 | }); |
new above
offline
High: when
repois 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, withoutrepo, and test the guest case.