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

/__forge/ on an app's host answers a small forge page: where you are, Home, the repository, tell the crew #3315

Merged opened this

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

Merged

scout/forge:scout/3308-forge-on-an-app-s-host-answers-a-small-f → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/3308-forge-on-an-app-s-host-answers-a-small-f was merged into main as 08807e6.

Review

Sign in to review this change.
4 files changed +264 −0
README.md +8
@@ -910,6 +910,14 @@ pull, the panel also says `scout is working on this` and links the run; it asks
910910 read the repository (and, for a run on a sealed issue, that issue), and shows
911911 nothing when the crew is idle.
912912
913 **When the pill is not shown** (an app whose own policy refuses foreign
914 scripts), `/__forge/` on the app's host is the way back: a small page from the
915 forge, never the app, with no script, saying the app and where you are
916 (`production` or `preview of pull #N`) and linking Home, the repository (to
917 who may read it) and *Tell the crew* (the same link `/__forge/crew` gives; signed
918 out it offers sign in). The prefix was already the forge's, so an app's own
919 `/__forge/` path is not reachable.
920
913921 **Tell the crew from the pill.** Signed in as someone who may direct the crew,
914922 the panel also offers *Tell the crew* (`/__forge/crew` adds a `tell` link, only
915923 for them; signed out there is no action). It opens the forge's own
src/apps/door.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * `GET /__forge/` on an app's host: the way back when the pill is not shown
3 * (an app whose own policy refuses foreign scripts). Answered by the proxy,
4 * never by the app; plain data in, one static-looking page out, no script.
5 */
6
7 import { createHash } from "node:crypto";
8 import { escapeAttr, type PillWhere } from "./toolbar.ts";
9
10 export type DoorFacts = {
11 where: PillWhere;
12 forgeUrl: string;
13 /** The forge's tell page, only for someone who may direct the crew. */
14 tell?: string;
15 /** This host's own sign-in door, offered when nobody is signed in. */
16 signin?: string;
17 };
18
19 const STYLE =
20 "body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}li{margin:.4rem 0}";
21 const STYLE_HASH = createHash("sha256").update(STYLE).digest("base64");
22
23 /** The same words as the pill's accessible name. */
24 export function doorWhere(where: PillWhere): string {
25 return where.env === "production"
26 ? "production"
27 : where.pull !== undefined
28 ? `preview of pull #${where.pull}`
29 : where.env;
30 }
31
32 const link = (href: string, text: string): string =>
33 `<li><a href="${escapeAttr(href)}">${escapeAttr(text)}</a></li>`;
34
35 export function doorPage(facts: DoorFacts, head: boolean): Response {
36 const { where, forgeUrl } = facts;
37 const links = [link(forgeUrl, "Home")];
38 if (where.repo !== undefined)
39 links.push(
40 link(new URL(`/${where.repo}`, forgeUrl).href, `The repository, ${where.repo}`),
41 );
42 if (facts.tell !== undefined) links.push(link(facts.tell, "Tell the crew"));
43 else if (facts.signin !== undefined)
44 links.push(link(facts.signin, "Sign in to tell the crew"));
45 const html = `<!doctype html>
46 <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>
47 <body><main><h1>${escapeAttr(where.app)}</h1><p>${escapeAttr(doorWhere(where))}</p><ul>${links.join("")}</ul></main></body></html>
48 `;
49 return new Response(head ? null : html, {
50 headers: {
51 "content-type": "text/html; charset=utf-8",
52 "cache-control": "no-store",
53 "x-content-type-options": "nosniff",
54 "referrer-policy": "same-origin",
55 "content-security-policy": `default-src 'none'; style-src 'sha256-${STYLE_HASH}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
56 },
57 });
58 }
src/apps/proxy.ts +31
@@ -36,6 +36,8 @@ import { loadKey } from "../kernel/secret.ts";
3636 import { previewEnv, type EnvName } from "./data.ts";
3737 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
3838 import { crewNow, tellLink } from "./crew.ts";
39 import { doorPage } from "./door.ts";
40 import { notFound } from "../http/respond.ts";
3941 import { appMeasure } from "./measure.ts";
4042 import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
4143
@@ -598,6 +600,35 @@ export async function proxyApp(
598600 },
599601 });
600602 }
603 // The forge's small page on the app's own host: the way back when the pill
604 // is not shown. A nested host is its guest's, and has no such page.
605 if (url.pathname === "/__forge/") {
606 if (target.nested) return notFound();
607 if (request.method !== "GET" && request.method !== "HEAD")
608 return new Response(null, {
609 status: 405,
610 headers: { allow: "GET, HEAD", "x-content-type-options": "nosniff" },
611 });
612 const cookie = cookieValue(request, grantCookieName(target));
613 const signed =
614 cookie === undefined
615 ? undefined
616 : readGrant(runtime.app.config.dataDir, cookie, target);
617 const tell = tellLink(
618 runtime.app.db,
619 runtime.options.forgeUrl,
620 target.app,
621 signed?.who?.id,
622 );
623 return doorPage(
624 {
625 where: pillWhere(runtime, target, signed?.who),
626 forgeUrl: runtime.options.forgeUrl,
627 ...(tell === undefined ? { signin: "/__forge/signin" } : { tell }),
628 },
629 request.method === "HEAD",
630 );
631 }
601632 // The third door: a problem report, answered without waking or even
602633 // reaching the app — the app being down is the usual reason to ask. The
603634 // forge's form is told which page it came from.
test/apps-door.test.ts added +167
@@ -0,0 +1,167 @@
1 /** `/__forge/` on an app's host: the forge's own small page, no script (#3308). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { newApp } from "../src/actions/apps.ts";
9 import { signGrant, type AppTarget } from "../src/apps/proxy.ts";
10 import { AppsRuntime } from "../src/apps/runtime.ts";
11 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
12 import { createIssue } from "../src/core/issues.ts";
13 import { createUser, getUserById } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const DOMAIN = "apps.test";
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let runtime: AppsRuntime;
24 let record: AppRecord;
25 let alice: string;
26 let pull: number;
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-door-"));
30 app = createApp({ dataDir, port: 0, host: "forge.test" });
31 servers = await serve(app);
32 runtime = new AppsRuntime(app, {
33 domain: DOMAIN,
34 scheme: "http",
35 publicPort: servers.port,
36 forgeUrl: servers.url,
37 concurrency: 1,
38 });
39 await runtime.start();
40 const u = await createUser(app.db, {
41 origin: "seed",
42 trusted: true,
43 username: "alice",
44 email: "[email protected]",
45 password: "a-good-passphrase",
46 adminIfFirst: true,
47 });
48 if (!u.ok) throw new Error(u.error);
49 alice = u.value.id;
50 const made = await newApp(app, u.value, {
51 name: "notes",
52 description: "x",
53 visibility: "public",
54 });
55 if (!made.ok) throw new Error(String(made.error));
56 record = getAppByRepo(app.db, made.value.repo.id)!;
57 const p = createIssue(app.db, {
58 repoId: made.value.repo.id,
59 authorId: alice,
60 title: "<b>t</b>",
61 kind: "pull",
62 });
63 if (!p.ok) throw new Error(p.error);
64 pull = p.value.number;
65 }, 120_000);
66
67 afterAll(async () => {
68 await runtime.stop();
69 servers.stop();
70 app.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 const door = (host: string, cookie?: string) =>
75 fetch(`${servers.url}/__forge/`, {
76 headers: { host, ...(cookie === undefined ? {} : { cookie }) },
77 });
78
79 test("production: the app, where you are, Home and the repository; no script", async () => {
80 const res = await door(`notes.${DOMAIN}`);
81 expect(res.status).toBe(200);
82 expect(res.headers.get("content-type")).toContain("text/html");
83 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
84 expect(res.headers.get("referrer-policy")).toBe("same-origin");
85 expect(res.headers.get("content-security-policy")).toContain("default-src 'none'");
86 const html = await res.text();
87 expect(html).toContain("<h1>notes</h1>");
88 expect(html).toContain("<p>production</p>");
89 expect(html).toContain(`href="${servers.url}"`);
90 expect(html).toContain(`href="${servers.url}/alice/notes"`);
91 expect(html).toContain('href="/__forge/signin"');
92 expect(html).not.toContain("<script");
93 });
94
95 test("a preview says the pull, and a signed-in director gets the tell link", async () => {
96 const target = { app: record, env: `pull-${pull}` } as AppTarget;
97 const grant = signGrant(dataDir, target, 60_000, Date.now(), {
98 id: alice,
99 name: "alice",
100 role: "admin",
101 });
102 const html = await (
103 await door(`notes--${pull}.${DOMAIN}`, `forge_grant_${record.id.slice(-8)}=${grant}`)
104 ).text();
105 expect(html).toContain(`<p>preview of pull #${pull}</p>`);
106 expect(html).toContain(`href="${servers.url}/alice/notes/tell"`);
107 expect(html).toContain(">Tell the crew<");
108 expect(html).not.toContain("<b>t</b>");
109 });
110
111 test("a nested host is the guest's: not found", async () => {
112 const res = await door(`other--notes.${DOMAIN}`);
113 expect(res.status).toBe(404);

Add tests here: a private repo or a non-reader gets no repository link; POST gets 405 with allow; HEAD gets 200 with no body.

114 });
115
116 test("a private repository is linked only to who may read it", async () => {
117 const owner = getUserById(app.db, alice)!;
118 const made = await newApp(app, owner, {
119 name: "vault",
120 description: "x",
121 visibility: "private",
122 });
123 if (!made.ok) throw new Error(String(made.error));
124 const vault = getAppByRepo(app.db, made.value.repo.id)!;
125 const b = await createUser(app.db, {
126 origin: "seed",
127 trusted: true,
128 username: "bob",
129 email: "[email protected]",
130 password: "a-good-passphrase",
131 });
132 if (!b.ok) throw new Error(b.error);
133 const target = { app: vault, env: "production" } as AppTarget;
134 const cookie = `forge_grant_${vault.id.slice(-8)}`;
135 const as = (id: string, name: string, role: "admin" | "user") =>
136 `${cookie}=${signGrant(dataDir, target, 60_000, Date.now(), { id, name, role })}`;
137 const host = `vault.${DOMAIN}`;
138
139 for (const cookies of [undefined, as(b.value.id, "bob", "user")]) {
140 const html = await (await door(host, cookies)).text();
141 expect(html).toContain("<h1>vault</h1>");
142 expect(html).not.toContain("alice/vault");
143 expect(html).not.toContain("The repository");
144 }
145 const mine = await (await door(host, as(alice, "alice", "admin"))).text();
146 expect(mine).toContain(`href="${servers.url}/alice/vault"`);
147 expect(mine).toContain("alice/vault");
148 });
149
150 test("HEAD answers the headers with no body; POST is a 405 that says what is allowed", async () => {
151 const get = await door(`notes.${DOMAIN}`);
152 const head = await fetch(`${servers.url}/__forge/`, {
153 method: "HEAD",
154 headers: { host: `notes.${DOMAIN}` },
155 });
156 expect(head.status).toBe(200);
157 expect(await head.text()).toBe("");
158 for (const h of ["content-type", "content-security-policy", "cache-control"])
159 expect(head.headers.get(h)).toBe(get.headers.get(h));
160 const post = await fetch(`${servers.url}/__forge/`, {
161 method: "POST",
162 headers: { host: `notes.${DOMAIN}` },
163 });
164 expect(post.status).toBe(405);
165 expect(post.headers.get("allow")).toBe("GET, HEAD");
166 expect(post.headers.get("x-content-type-options")).toBe("nosniff");
167 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs