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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3308-forge-on-an-app-s-host-answers-a-small-f → main 3 commits fork 1 thread · 1 unresolved
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 | ||
| 910 | 910 | read the repository (and, for a run on a sealed issue, that issue), and shows |
| 911 | 911 | nothing when the crew is idle. |
| 912 | 912 | |
| 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 | ||
| 913 | 921 | **Tell the crew from the pill.** Signed in as someone who may direct the crew, |
| 914 | 922 | the panel also offers *Tell the crew* (`/__forge/crew` adds a `tell` link, only |
| 915 | 923 | 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"; | ||
| 36 | 36 | import { previewEnv, type EnvName } from "./data.ts"; |
| 37 | 37 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 38 | 38 | import { crewNow, tellLink } from "./crew.ts"; |
| 39 | import { doorPage } from "./door.ts"; | |
| 40 | import { notFound } from "../http/respond.ts"; | |
| 39 | 41 | import { appMeasure } from "./measure.ts"; |
| 40 | 42 | import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| 41 | 43 | |
| @@ -598,6 +600,35 @@ export async function proxyApp( | ||
| 598 | 600 | }, |
| 599 | 601 | }); |
| 600 | 602 | } |
| 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 | } | |
| 601 | 632 | // The third door: a problem report, answered without waking or even |
| 602 | 633 | // reaching the app — the app being down is the usual reason to ask. The |
| 603 | 634 | // 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); | |
| 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 | }); |
new above
offline
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.