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

Merged opened this

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

Merged

scout/forge:scout/6073-an-app-host-s-forge-door-lists-its-open → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/6073-an-app-host-s-forge-door-lists-its-open was merged into main as 34e3b0e.

Review

Sign in to review this change.
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
330330
331331 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`).
332332
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
333335 A big repository clones over either transport. To skip its large files until a
334336 checkout needs them, run `git clone --filter=blob:none <url>`. See
335337 [`docs/GIT.md`](docs/GIT.md).
src/apps/door.ts +17 −1
@@ -22,8 +22,15 @@ export type DoorFacts = {
2222 * `ssh …` command, or null when the terminal is off. Unset shows nothing.
2323 */
2424 ssh?: string | null;
25 /** Open pulls a viewer may see, each with where to go: its preview or the forge. */
26 pulls?: DoorPullLine[] | undefined;
2527 };
2628
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
2734 const STYLE =
2835 "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}";
2936 const STYLE_HASH = createHash("sha256").update(STYLE).digest("base64");
@@ -75,6 +82,15 @@ function servingHtml(facts: DoorFacts): string {
7582 return `<p>Serving ${sha(now)} since ${stamp(now.at)}</p>${older === "" ? "" : `<h2>Before it</h2><ul>${older}</ul>`}`;
7683 }
7784
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
7894 export function doorPage(facts: DoorFacts, head: boolean): Response {
7995 const { where, forgeUrl } = facts;
8096 const links = [link(forgeUrl, "Home")];
@@ -93,7 +109,7 @@ export function doorPage(facts: DoorFacts, head: boolean): Response {
93109 : `<p>Terminal:</p><pre><code>${escapeAttr(facts.ssh)}</code></pre>`;
94110 const html = `<!doctype html>
95111 <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>
97113 `;
98114 return new Response(head ? null : html, {
99115 headers: {
src/apps/proxy.ts +43 −1
@@ -39,7 +39,9 @@ import type { AppsRuntime, EnvStatus } from "./runtime.ts";
3939 import { crewNow, tellLink } from "./crew.ts";
4040 import { feedback, mintProof } from "./feedback.ts";
4141 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";
4345 import { takePhases, timePhase } from "../core/route_timing.ts";
4446 import { readServing } from "./serving.ts";
4547 import { notFound, securityFloor } from "../http/respond.ts";
@@ -436,6 +438,42 @@ function doorSsh(
436438 return runtime.sshFor(target.app, target.env) ?? null;
437439 }
438440
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 */
outdated

Duplicate of DOOR_PULL_LIMIT in door.ts. Import that one, drop this, and put the doc comment above doorPulls.

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
439477 /** Whether the person a grant names may enter this app now. */
440478 function stillEntitled(
441479 runtime: AppsRuntime,
@@ -692,10 +730,14 @@ export async function proxyApp(
692730 const serving = timePhase(request, "serving", () =>
693731 readServing(runtime.app.db, target.app.repoId, target.env),
694732 );
733 const pulls = timePhase(request, "pulls", () =>
734 doorPulls(runtime, target, signed?.who),
735 );
695736 const page = timePhase(request, "render", () =>
696737 doorPage(
697738 {
698739 where,
740 pulls,
699741 ...(serving === undefined ? {} : { serving }),
700742 forgeUrl: runtime.options.forgeUrl,
701743 ...(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 () => {
132132 password: "a-good-passphrase",
133133 });
134134 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);
135147 const target = { app: vault, env: "production" } as AppTarget;
136148 const cookie = `forge_grant_${vault.id.slice(-8)}`;
137149 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 () => {
148160 expect(res.status).toBe(unknown.status);
149161 expect(body).toBe(unknownBody);
150162 expect(body).not.toContain("vault");
163 expect(body).not.toContain("secret plan");
151164 expect([...res.headers].filter(([k]) => k !== "date")).toEqual(
152165 [...unknown.headers].filter(([k]) => k !== "date"),
153166 );
@@ -157,6 +170,7 @@ test("a private repository is linked only to who may read it", async () => {
157170 const mine = await (await door(host, as(alice, "alice", "admin"))).text();
158171 expect(mine).toContain(`href="${servers.url}/alice/vault"`);
159172 expect(mine).toContain("alice/vault");
173 expect(mine).toContain("secret plan · check running");
160174 });
161175
162176 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
260274 [`notes--${pull}.${DOMAIN}`, grantFor(`pull-${pull}`)],
261275 ];
262276 const reads: Record<string, string[]> = {
263 "/__forge/": ["session", "app", "pull", "serving", "render"],
277 "/__forge/": ["session", "app", "pull", "serving", "pulls", "render"],
264278 "/__forge/crew": ["session", "app", "pull", "working", "render"],
265279 };
266280 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
279293 for (const ms of crew.values()) expect(Number.isFinite(ms)).toBe(true);
280294 // The door still looks the app and pull up for its words.
281295 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"]);
283297 }
284298 });
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} &lt;b&gt;t&lt;/b&gt; · check green</a>`,
343 );
344 expect(html).toContain(
345 `<a href="${servers.url}/alice/notes/pulls/${cold.value.number}">#${cold.value.number} cold &lt;i&gt;one&lt;/i&gt; · 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs