Public: anyone can read this; the crew can change it.
An app host's /__forge/ door lists its open pulls with their check word, linking each to its preview #6157
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6073-an-app-host-s-forge-door-lists-its-open → main 5 commits fork 1 thread · 1 unresolved
4 files
changed +166
−4
README.md +2
| @@ -330,6 +330,8 @@ ssh -p 2222 localhost tell make the header blue # tell the crew in one line | ||
| 330 | 330 | |
| 331 | 331 | An app's open pulls, newest five first, one line each (`#n title · green|red|running|none`), need no tty: run `pulls` on the app's login — `ssh <app>@<forge> pulls`, with the app's page printing the exact command as `FORGE_APP_SSH` (the terminal is off when that is unset). A private app answers only a key its gate admits; sealed pulls show to their author, writers and the crew (`src/apps/door_pulls.ts`). |
| 332 | 332 | |
| 333 | The app's `/__forge/` door on its own host lists the same five pulls with their check word (`#n title · check green`). A running, asleep or dormant preview links to itself, any other pull to its page on the forge. Who sees which is the rule in `src/apps/door_pulls.ts`; a stranger at a private app gets the same 404 as for an unknown app. When more than five are open the door says so in one line; the rest are on the forge. | |
| 334 | ||
| 333 | 335 | A big repository clones over either transport. To skip its large files until a |
| 334 | 336 | checkout needs them, run `git clone --filter=blob:none <url>`. See |
| 335 | 337 | [](docs/GIT.md). |
src/apps/door.ts +17 −1
| @@ -22,8 +22,15 @@ export type DoorFacts = { | ||
| 22 | 22 | * `ssh …` command, or null when the terminal is off. Unset shows nothing. |
| 23 | 23 | */ |
| 24 | 24 | ssh?: string | null; |
| 25 | /** Open pulls a viewer may see, each with where to go: its preview or the forge. */ | |
| 26 | pulls?: DoorPullLine[] | undefined; | |
| 25 | 27 | }; |
| 26 | 28 | |
| 29 | /** How many pulls the door lists; one more read than this means there are others. */ | |
| 30 | export const DOOR_PULL_LIMIT = 5; | |
| 31 | ||
| 32 | export type DoorPullLine = { number: number; title: string; check: string; href: string }; | |
| 33 | ||
| 27 | 34 | const STYLE = |
| 28 | 35 | "body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}li{margin:.4rem 0}pre{overflow-x:auto;padding:.6rem;background:#eee;color:#111;user-select:all}"; |
| 29 | 36 | const STYLE_HASH = createHash("sha256").update(STYLE).digest("base64"); |
| @@ -75,6 +82,15 @@ function servingHtml(facts: DoorFacts): string { | ||
| 75 | 82 | return `<p>Serving ${sha(now)} since ${stamp(now.at)}</p>${older === "" ? "" : `<h2>Before it</h2><ul>${older}</ul>`}`; |
| 76 | 83 | } |
| 77 | 84 | |
| 85 | function pullsHtml(pulls: DoorPullLine[] | undefined): string { | |
| 86 | if (pulls === undefined || pulls.length === 0) return ""; | |
| 87 | const items = pulls.slice(0, DOOR_PULL_LIMIT).map((p) => | |
| 88 | link(p.href, `#${p.number} ${[...p.title].slice(0, 120).join("")} · check ${p.check}`), | |
| 89 | ); | |
| 90 | const more = pulls.length > DOOR_PULL_LIMIT ? "<p>More pulls are open on the forge.</p>" : ""; | |
| 91 | return `<h2>Open pulls</h2><ul>${items.join("")}</ul>${more}`; | |
| 92 | } | |
| 93 | ||
| 78 | 94 | export function doorPage(facts: DoorFacts, head: boolean): Response { |
| 79 | 95 | const { where, forgeUrl } = facts; |
| 80 | 96 | const links = [link(forgeUrl, "Home")]; |
| @@ -93,7 +109,7 @@ export function doorPage(facts: DoorFacts, head: boolean): Response { | ||
| 93 | 109 | : `<p>Terminal:</p><pre><code>${escapeAttr(facts.ssh)}</code></pre>`; |
| 94 | 110 | const html = `<!doctype html> |
| 95 | 111 | <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> |
| 96 | <body><main><h1>${escapeAttr(where.app)}</h1><p>${escapeAttr(doorWhere(where))}</p>${servingHtml(facts)}<ul>${links.join("")}</ul>${terminal}</main></body></html> | |
| 112 | <body><main><h1>${escapeAttr(where.app)}</h1><p>${escapeAttr(doorWhere(where))}</p>${servingHtml(facts)}${pullsHtml(facts.pulls)}<ul>${links.join("")}</ul>${terminal}</main></body></html> | |
| 97 | 113 | `; |
| 98 | 114 | return new Response(head ? null : html, { |
| 99 | 115 | headers: { |
src/apps/proxy.ts +43 −1
| @@ -39,7 +39,9 @@ import type { AppsRuntime, EnvStatus } from "./runtime.ts"; | ||
| 39 | 39 | import { crewNow, tellLink } from "./crew.ts"; |
| 40 | 40 | import { feedback, mintProof } from "./feedback.ts"; |
| 41 | 41 | import { clientBucket } from "../http/peer.ts"; |
| 42 | import { doorPage } from "./door.ts"; | |
| 42 | import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts"; | |
| 43 | import { checkWord } from "./check_word.ts"; | |
| 44 | import { openPulls } from "./door_pulls.ts"; | |
| 43 | 45 | import { takePhases, timePhase } from "../core/route_timing.ts"; |
| 44 | 46 | import { readServing } from "./serving.ts"; |
| 45 | 47 | import { notFound, securityFloor } from "../http/respond.ts"; |
| @@ -436,6 +438,42 @@ function doorSsh( | ||
| 436 | 438 | return runtime.sshFor(target.app, target.env) ?? null; |
| 437 | 439 | } |
| 438 | 440 | |
| 441 | /** | |
| 442 | * The door's open pulls: a preview that is running or asleep links to itself, | |
| 443 | * every other pull to its page on the forge. Empty for whoever may not read. | |
| 444 | * Reads one more than the door draws, so it can say there are others. | |
| 445 | */ | |
| 446 | function doorPulls( | |
| 447 | runtime: AppsRuntime, | |
| 448 | target: AppTarget, | |
| 449 | who: GrantWho | undefined, | |
| 450 | ): DoorPullLine[] { | |
| 451 | const db = runtime.app.db; | |
| 452 | const repo = getRepoById(db, target.app.repoId); | |
| 453 | if (repo === undefined) return []; | |
| 454 | const viewer = who === undefined ? undefined : getUserById(db, who.id); | |
| 455 | return openPulls(db, repo, viewer, DOOR_PULL_LIMIT + 1).map((p) => { | |
| 456 | const env = previewEnv(p.number); | |
| 457 | const state = runtime.envStatus(target.app, env); | |
| 458 | const live = | |
| 459 | state !== undefined && | |
| 460 | (state.state === "running" || | |
| 461 | state.state === "asleep" || | |
| 462 | state.dormant !== undefined); | |
| 463 | return { | |
| 464 | number: p.number, | |
| 465 | title: p.title, | |
| 466 | check: checkWord(p.run, p.headOid), | |
| 467 | href: live | |
| 468 | ? runtime.urlFor(target.app, env) | |
| 469 | : new URL( | |
| 470 | `/${repo.ownerName}/${repo.name}/pulls/${p.number}`, | |
| 471 | runtime.options.forgeUrl, | |
| 472 | ).href, | |
| 473 | }; | |
| 474 | }); | |
| 475 | } | |
| 476 | ||
| 439 | 477 | /** Whether the person a grant names may enter this app now. */ |
| 440 | 478 | function stillEntitled( |
| 441 | 479 | runtime: AppsRuntime, |
| @@ -692,10 +730,14 @@ export async function proxyApp( | ||
| 692 | 730 | const serving = timePhase(request, "serving", () => |
| 693 | 731 | readServing(runtime.app.db, target.app.repoId, target.env), |
| 694 | 732 | ); |
| 733 | const pulls = timePhase(request, "pulls", () => | |
| 734 | doorPulls(runtime, target, signed?.who), | |
| 735 | ); | |
| 695 | 736 | const page = timePhase(request, "render", () => |
| 696 | 737 | doorPage( |
| 697 | 738 | { |
| 698 | 739 | where, |
| 740 | pulls, | |
| 699 | 741 | ...(serving === undefined ? {} : { serving }), |
| 700 | 742 | forgeUrl: runtime.options.forgeUrl, |
| 701 | 743 | ...(ssh === undefined ? {} : { ssh }), |
test/apps-door.test.ts +104 −2
| @@ -132,6 +132,18 @@ test("a private repository is linked only to who may read it", async () => { | ||
| 132 | 132 | password: "a-good-passphrase", |
| 133 | 133 | }); |
| 134 | 134 | if (!b.ok) throw new Error(b.error); |
| 135 | const secret = createIssue(app.db, { | |
| 136 | repoId: vault.repoId, | |
| 137 | authorId: alice, | |
| 138 | title: "secret plan", | |
| 139 | kind: "pull", | |
| 140 | }); | |
| 141 | if (!secret.ok) throw new Error(secret.error); | |
| 142 | app.db | |
| 143 | .query( | |
| 144 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', 'h', 'b', 'x')", | |
| 145 | ) | |
| 146 | .run(secret.value.id); | |
| 135 | 147 | const target = { app: vault, env: "production" } as AppTarget; |
| 136 | 148 | const cookie = `forge_grant_${vault.id.slice(-8)}`; |
| 137 | 149 | const as = (id: string, name: string, role: "admin" | "user") => |
| @@ -148,6 +160,7 @@ test("a private repository is linked only to who may read it", async () => { | ||
| 148 | 160 | expect(res.status).toBe(unknown.status); |
| 149 | 161 | expect(body).toBe(unknownBody); |
| 150 | 162 | expect(body).not.toContain("vault"); |
| 163 | expect(body).not.toContain("secret plan"); | |
| 151 | 164 | expect([...res.headers].filter(([k]) => k !== "date")).toEqual( |
| 152 | 165 | [...unknown.headers].filter(([k]) => k !== "date"), |
| 153 | 166 | ); |
| @@ -157,6 +170,7 @@ test("a private repository is linked only to who may read it", async () => { | ||
| 157 | 170 | const mine = await (await door(host, as(alice, "alice", "admin"))).text(); |
| 158 | 171 | expect(mine).toContain(`href="${servers.url}/alice/vault"`); |
| 159 | 172 | expect(mine).toContain("alice/vault"); |
| 173 | expect(mine).toContain("secret plan · check running"); | |
| 160 | 174 | }); |
| 161 | 175 | |
| 162 | 176 | test("HEAD answers the headers with no body; POST is a 405 that says what is allowed", async () => { |
| @@ -260,7 +274,7 @@ test("the door and the crew line name the phases they read, signed in, on produc | ||
| 260 | 274 | [`notes--${pull}.${DOMAIN}`, grantFor(`pull-${pull}`)], |
| 261 | 275 | ]; |
| 262 | 276 | const reads: Record<string, string[]> = { |
| 263 | "/__forge/": ["session", "app", "pull", "serving", "render"], | |
| 277 | "/__forge/": ["session", "app", "pull", "serving", "pulls", "render"], | |
| 264 | 278 | "/__forge/crew": ["session", "app", "pull", "working", "render"], |
| 265 | 279 | }; |
| 266 | 280 | for (const [host, cookie] of hosts) |
| @@ -279,6 +293,94 @@ test("a guest names session and render, and the crew line stops before the reads | ||
| 279 | 293 | for (const ms of crew.values()) expect(Number.isFinite(ms)).toBe(true); |
| 280 | 294 | // The door still looks the app and pull up for its words. |
| 281 | 295 | const page = await phasesOf(host, "/__forge/"); |
| 282 | expect([...page.keys()].sort()).toEqual(["app", "pull", "render", "serving", "session"]); | |
| 296 | expect([...page.keys()].sort()).toEqual(["app", "pull", "pulls", "render", "serving", "session"]); | |
| 283 | 297 | } |
| 284 | 298 | }); |
| 299 | ||
| 300 | test("the door lists open pulls with their check word: a preview link when it is up or asleep, the forge's page otherwise, titles escaped", async () => { | |
| 301 | const repoId = record.repoId; | |
| 302 | const addPull = (n: number, head: string) => { | |
| 303 | const id = ( | |
| 304 | app.db.query("SELECT id FROM issue WHERE repo_id = ? AND number = ?").get(repoId, n) as { | |
| 305 | id: string; | |
| 306 | } | |
| 307 | ).id; | |
| 308 | app.db | |
| 309 | .query( | |
| 310 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)", | |
| 311 | ) | |
| 312 | .run(id, `h${n}`, head); | |
| 313 | return id; | |
| 314 | }; | |
| 315 | const sleeping = addPull(pull, "head1"); | |
| 316 | app.db | |
| 317 | .query( | |
| 318 | "INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, 'head1', 'ok')", | |
| 319 | ) | |
| 320 | .run(crypto.randomUUID(), repoId, sleeping); | |
| 321 | const cold = createIssue(app.db, { | |
| 322 | repoId, | |
| 323 | authorId: alice, | |
| 324 | title: "cold <i>one</i>", | |
| 325 | kind: "pull", | |
| 326 | }); | |
| 327 | if (!cold.ok) throw new Error(cold.error); | |
| 328 | addPull(cold.value.number, "head2"); | |
| 329 | (runtime as unknown as { envs: Map<string, unknown> }).envs.set( | |
| 330 | `${record.id}/pull-${pull}`, | |
| 331 | { | |
| 332 | app: record, | |
| 333 | env: `pull-${pull}`, | |
| 334 | chain: Promise.resolve(), | |
| 335 | restarts: [], | |
| 336 | asleep: { commitOid: "head1", runId: "r", since: new Date().toISOString() }, | |
| 337 | }, | |
| 338 | ); | |
| 339 | const html = await (await door(`notes.${DOMAIN}`)).text(); | |
| 340 | const live = runtime.urlFor(record, `pull-${pull}`); | |
| 341 | expect(html).toContain( | |
| 342 | `<a href="${live}">#${pull} <b>t</b> · check green</a>`, | |
| 343 | ); | |
| 344 | expect(html).toContain( | |
| 345 | `<a href="${servers.url}/alice/notes/pulls/${cold.value.number}">#${cold.value.number} cold <i>one</i> · check none</a>`, | |
| 346 | ); | |
| 347 | expect(html).not.toContain("<b>t</b>"); | |
| 348 | expect(html).not.toContain("<i>"); | |
| 349 | }); | |
| 350 | ||
| 351 | test("the door links a dormant preview to itself, and says so when more than five pulls are open", async () => { | |
| 352 | const repoId = record.repoId; | |
| 353 | const mk = (title: string) => { | |
| 354 | const r = createIssue(app.db, { repoId, authorId: alice, title, kind: "pull" }); | |
| 355 | if (!r.ok) throw new Error(r.error); | |
| 356 | const id = ( | |
| 357 | app.db.query("SELECT id FROM issue WHERE repo_id = ? AND number = ?").get(repoId, r.value.number) as { | |
| 358 | id: string; | |
| 359 | } | |
| 360 | ).id; | |
| 361 | app.db | |
| 362 | .query( | |
| 363 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', 'h')", | |
| 364 | ) | |
| 365 | .run(id, `d${r.value.number}`); | |
| 366 | return r.value.number; | |
| 367 | }; | |
| 368 | const dormant = mk("quiet one"); | |
| 369 | (runtime as unknown as { envs: Map<string, unknown> }).envs.set( | |
| 370 | `${record.id}/pull-${dormant}`, | |
| 371 | { | |
| 372 | app: record, | |
| 373 | env: `pull-${dormant}`, | |
| 374 | chain: Promise.resolve(), | |
| 375 | restarts: [], | |
| 376 | dormant: { since: new Date().toISOString(), idleSince: new Date().toISOString(), commitOid: "h" }, | |
| 377 | }, | |
| 378 | ); | |
| 379 | let html = await (await door(`notes.${DOMAIN}`)).text(); | |
| 380 | expect(html).toContain(`<a href="${runtime.urlFor(record, `pull-${dormant}`)}">#${dormant} quiet one`); | |
| 381 | expect(html).not.toContain("More pulls are open"); | |
| 382 | for (let i = 0; i < 5; i++) mk(`extra ${i}`); | |
| 383 | html = await (await door(`notes.${DOMAIN}`)).text(); | |
| 384 | expect(html).toContain("More pulls are open on the forge."); | |
| 385 | expect((html.match(/ · check /g) ?? []).length).toBe(5); | |
| 386 | }); |
new above
offline
Duplicate of DOOR_PULL_LIMIT in door.ts. Import that one, drop this, and put the doc comment above doorPulls.