Public: anyone can read this; the crew can change it.
A public app's pages carry a Link to the forge's manifest for its repository, so an agent landing on the app finds what it can do #7869
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7861-a-public-app-s-pages-carry-a-link-to-the → main 2 commits fork 4 threads · 4 unresolved
4 files
changed +197
−21
README.md +6
| @@ -364,6 +364,12 @@ work here. | ||
| 364 | 364 | |
| 365 | 365 | ## What works today |
| 366 | 366 | |
| 367 | A public app of a public repository tells an agent that lands on it what it can | |
| 368 | do: its HTML pages, and the "not running" page, carry two `Link` headers — | |
| 369 | `rel="help"` to `/__forge/report` and `rel="alternate"` to the forge's | |
| 370 | `/api/v1/here?path=/<owner>/<repo>` manifest (`discoveryLinks`, | |
| 371 | `src/apps/proxy.ts`). A private app, a nested host and non-HTML answers get none. | |
| 372 | ||
| 367 | 373 | Open an image or SVG in a repository and it is drawn, fit to the pane. Press |
| 368 | 374 | **Actual size** (or click the picture) for 100%; the pane scrolls or drags to |
| 369 | 375 | pan. The header shows its pixel size, file size and format; **Source**, |
docs/APPS.md +3 −1
| @@ -917,7 +917,9 @@ repository: it sends anyone, with no account, to the repository's report | ||
| 917 | 917 | form (STANDARDS.md, "Reports from outside") with the page they came from, |
| 918 | 918 | and is answered without waking the app, since the app being down is the |
| 919 | 919 | usual reason to ask. Such an app's HTML responses, and the forge's own "not |
| 920 | running" page for it, carry `Link: </__forge/report>; rel="help"`, and the | |
| 920 | running" page for it, carry `Link: </__forge/report>; rel="help"` and a second `Link` with | |
| 921 | `rel="alternate"; type="application/json"` to the forge's | |
| 922 | `/api/v1/here?path=/<owner>/<repo>` manifest, and the | |
| 921 | 923 | template's footer links to it. A private app or repository offers none of |
| 922 | 924 | this. |
| 923 | 925 |
src/apps/proxy.ts +31 −10
| @@ -753,20 +753,40 @@ function admit( | ||
| 753 | 753 | * public. A private one offers nothing, and a nested host is its guest's. |
| 754 | 754 | */ |
| 755 | 755 | export function reportUrl(runtime: AppsRuntime, target: AppTarget): URL | undefined { |
| 756 | const repo = publicRepo(runtime, target); | |
| 757 | return repo === undefined ? undefined : reportOf(runtime, repo); | |
| 758 | } | |
| 759 | ||
| 760 | /** The repository of an app that is public along with it, else nothing. */ | |
| 761 | function publicRepo(runtime: AppsRuntime, target: AppTarget): Repo | undefined { | |
| 756 | 762 | if (target.nested || target.app.access !== "public") return undefined; |
| 757 | 763 | const repo = getRepoById(runtime.app.db, target.app.repoId); |
| 758 | if (repo === undefined || !canRead(runtime.app.db, repo, undefined)) | |
| 759 | return undefined; | |
| 760 | return new URL( | |
| 761 | `/${repo.ownerName}/${repo.name}/report`, | |
| 762 | runtime.options.forgeUrl, | |
| 763 | ); | |
| 764 | return repo !== undefined && canRead(runtime.app.db, repo, undefined) | |
| 765 | ? repo | |
| 766 | : undefined; | |
| 764 | 767 | } |
| 765 | 768 | |
| 769 | const reportOf = (runtime: AppsRuntime, repo: Repo): URL => | |
| 770 | new URL(`/${repo.ownerName}/${repo.name}/report`, runtime.options.forgeUrl); | |
| 771 | ||
| 766 | 772 | /** The link relation a public app's pages carry to `/__forge/report`. */ |
| 767 | 773 | const REPORT_LINK = |
| 768 | 774 | `</__forge/report>; rel="help"; title="${REPORT_LINK_TITLE}"`; |
| 769 | 775 | |
| 776 | /** | |
| 777 | * The `Link` values a public app's pages carry: the report door and the | |
| 778 | * forge's role-aware manifest for the app's repository, so an agent that | |
| 779 | * landed on the app finds what it can do. One repository read serves both; | |
| 780 | * a private app, a nested host or a vanished repository gets none. | |
| 781 | */ | |
| 782 | export function discoveryLinks(runtime: AppsRuntime, target: AppTarget): string[] { | |
| 783 | const repo = publicRepo(runtime, target); | |
| 784 | if (repo === undefined) return []; | |
| 785 | const here = new URL("/api/v1/here", runtime.options.forgeUrl); | |
| 786 | here.search = `?path=${encodeURIComponent(`/${repo.ownerName}/${repo.name}`)}`; | |
| 787 | return [REPORT_LINK, `<${here.href}>; rel="alternate"; type="application/json"`]; | |
| 788 | } | |
| 789 | ||
| 770 | 790 | const isHtml = (headers: Headers): boolean => |
| 771 | 791 | (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html"); |
| 772 | 792 | |
| @@ -1068,8 +1088,8 @@ export async function proxyApp( | ||
| 1068 | 1088 | for (const name of HOP_BY_HOP) out.delete(name); |
| 1069 | 1089 | // An agent reading a public app's page learns where to report a problem |
| 1070 | 1090 | // without the app having to say so. |
| 1071 | if (isHtml(out) && reportUrl(runtime, target) !== undefined) | |
| 1072 | out.append("link", REPORT_LINK); | |
| 1091 | if (isHtml(out)) | |
| 1092 | for (const link of discoveryLinks(runtime, target)) out.append("link", link); | |
| 1073 | 1093 | const eventStream = |
| 1074 | 1094 | response.body !== null && |
| 1075 | 1095 | (response.headers.get("content-type") ?? "") |
| @@ -1526,7 +1546,8 @@ function unavailable( | ||
| 1526 | 1546 | : gone |
| 1527 | 1547 | ? "This preview is gone: its pull was closed, and its data with it." |
| 1528 | 1548 | : "Nothing is running here yet."); |
| 1529 | const report = reportUrl(runtime, target) !== undefined; | |
| 1549 | const links = discoveryLinks(runtime, target); | |
| 1550 | const report = links.length > 0; | |
| 1530 | 1551 | const runLink = |
| 1531 | 1552 | status?.runId === undefined |
| 1532 | 1553 | ? "" |
| @@ -1555,7 +1576,7 @@ ${report ? '<p><a href="/__forge/report">Report a problem</a> to the people who | ||
| 1555 | 1576 | "content-type": "text/html; charset=utf-8", |
| 1556 | 1577 | "cache-control": "no-store", |
| 1557 | 1578 | ...(deploying ? { "retry-after": "3" } : {}), |
| 1558 | ...(report ? { link: REPORT_LINK } : {}), | |
| 1579 | ...(report ? { link: links.join(", ") } : {}), | |
| 1559 | 1580 | }, |
| 1560 | 1581 | }); |
| 1561 | 1582 | } |
test/report-discovery.test.ts +157 −10
| @@ -5,17 +5,28 @@ | ||
| 5 | 5 | * and each refusal it meets on the way. A private repository gives it |
| 6 | 6 | * nothing to follow. |
| 7 | 7 | */ |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 9 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | 15 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 18 | import { createUser } from "../src/core/users.ts"; |
| 16 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 20 | |
| 21 | /** The target of the first link relation `rel` in a `Link` header. */ | |
| 22 | const linkTo = (res: Response, rel: string): string | undefined => | |
| 23 | (res.headers.get("link") ?? "") | |
| 24 | .split(",") | |
| 25 | .map((part) => /<([^>]+)>\s*;\s*rel="([^"]+)"/.exec(part.trim())) | |
| 26 | .find((m) => m?.[2] === rel)?.[1]; | |
| 27 | ||
| 18 | 28 | describe("an outside agent finds the report path unaided", () => { |
| 29 | quietLogs(); | |
outdated
Top-level quietLogs() now also affects the first describe. Scope it to the new describe, or confirm that is intended. |
||
| 19 | 30 | let dataDir: string; |
| 20 | 31 | let app: App; |
| 21 | 32 | let servers: Servers; |
| @@ -29,7 +40,9 @@ describe("an outside agent finds the report path unaided", () => { | ||
| 29 | 40 | app = createApp({ dataDir, port: 0 }); |
| 30 | 41 | servers = await serve(app); |
| 31 | 42 | base = servers.url; |
| 32 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 43 | const a = await createUser(app.db, { | |
| 44 | origin: "seed", | |
| 45 | trusted: true, | |
| 33 | 46 | username: "alice", |
| 34 | 47 | email: "[email protected]", |
| 35 | 48 | password: "a-good-passphrase", |
| @@ -62,13 +75,6 @@ describe("an outside agent finds the report path unaided", () => { | ||
| 62 | 75 | .get() as { n: number } |
| 63 | 76 | ).n; |
| 64 | 77 | |
| 65 | /** The target of the first link relation `rel` in a `Link` header. */ | |
| 66 | const linkTo = (res: Response, rel: string): string | undefined => | |
| 67 | (res.headers.get("link") ?? "") | |
| 68 | .split(",") | |
| 69 | .map((part) => /<([^>]+)>\s*;\s*rel="([^"]+)"/.exec(part.trim())) | |
| 70 | .find((m) => m?.[2] === rel)?.[1]; | |
| 71 | ||
| 72 | 78 | test("the well-known files lead to llms.txt, and llms.txt says how to report", async () => { |
outdated
Indentation of this test was broken. Run the formatter. |
||
| 73 | 79 | const security = await ( |
| 74 | 80 | await fetch(`${base}/.well-known/security.txt`) |
| @@ -126,7 +132,10 @@ describe("an outside agent finds the report path unaided", () => { | ||
| 126 | 132 | const filed = await fetch(hint.report.api, { |
| 127 | 133 | method: "POST", |
| 128 | 134 | headers: { "content-type": "application/json", ...from() }, |
| 129 | body: JSON.stringify({ title: "The game is down", page: "https://game.example/" }), | |
| 135 | body: JSON.stringify({ | |
| 136 | title: "The game is down", | |
| 137 | page: "https://game.example/", | |
| 138 | }), | |
| 130 | 139 | }); |
| 131 | 140 | expect(filed.status).toBe(202); |
| 132 | 141 | expect(waiting()).toBe(before + 1); |
| @@ -244,3 +253,141 @@ describe("an outside agent finds the report path unaided", () => { | ||
| 244 | 253 | expect(((await signed.json()) as any).result).toEqual({}); |
| 245 | 254 | }); |
| 246 | 255 | }); |
| 256 | ||
| 257 | describe("a public app's pages point at the forge's manifest for its repository", () => { | |
| 258 | quietLogs(); | |
| 259 | const DOMAIN = "apps.test"; | |
| 260 | let dataDir: string; | |
| 261 | let app: App; | |
| 262 | let servers: Servers; | |
| 263 | let runtime: AppsRuntime; | |
| 264 | let backend: ReturnType<typeof Bun.serve>; | |
| 265 | let status: ReturnType<typeof spyOn>; | |
| 266 | ||
| 267 | beforeAll(async () => { | |
| 268 | backend = Bun.serve({ | |
| 269 | port: 0, | |
| 270 | fetch(request) { | |
| 271 | const path = new URL(request.url).pathname; | |
| 272 | if (path === "/json") return Response.json({ a: 1 }); | |
| 273 | return new Response("<html><body>hi</body></html>", { | |
| 274 | headers: { | |
| 275 | "content-type": "text/html; charset=utf-8", | |
| 276 | link: '</own.css>; rel="stylesheet"', | |
| 277 | }, | |
| 278 | }); | |
| 279 | }, | |
| 280 | }); | |
| 281 | dataDir = await mkdtemp(join(tmpdir(), "forge-manifest-link-")); | |
| 282 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 283 | servers = await serve(app); | |
| 284 | runtime = new AppsRuntime(app, { | |
| 285 | domain: DOMAIN, | |
| 286 | scheme: "http", | |
| 287 | publicPort: servers.port, | |
| 288 | forgeUrl: servers.url, | |
| 289 | concurrency: 1, | |
| 290 | }); | |
| 291 | await runtime.start(); | |
| 292 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 293 | () => | |
| 294 | ({ | |
| 295 | env: "production", | |
outdated
The createUser call is squashed onto one line. Format it like the rest of the file. |
||
| 296 | state: "running", | |
| 297 | port: backend.port, | |
| 298 | commitOid: "a".repeat(40), | |
| 299 | }) as EnvStatus, | |
| 300 | ); | |
| 301 | const a = await createUser(app.db, { | |
| 302 | origin: "seed", | |
| 303 | trusted: true, | |
| 304 | username: "alice", | |
| 305 | email: "[email protected]", | |
| 306 | password: "a-good-passphrase", | |
| 307 | adminIfFirst: true, | |
| 308 | }); | |
| 309 | if (!a.ok) throw new Error(a.error); | |
| 310 | for (const [name, visibility] of [ | |
| 311 | ["notes", "public"], | |
| 312 | ["diary", "private"], | |
| 313 | ] as const) { | |
| 314 | const made = await newApp(app, a.value, { | |
| 315 | name, | |
| 316 | description: "Words", | |
| 317 | visibility, | |
| 318 | }); | |
| 319 | if (!made.ok) throw new Error(String(made.error)); | |
| 320 | } | |
| 321 | }, 120_000); | |
| 322 | afterAll(async () => { | |
| 323 | status.mockRestore(); | |
| 324 | await runtime.stop(); | |
| 325 | servers.stop(); | |
| 326 | backend.stop(true); | |
| 327 | app.close(); | |
| 328 | await rm(dataDir, { recursive: true, force: true }); | |
| 329 | }); | |
| 330 | ||
| 331 | const at = (slug: string, path: string) => | |
| 332 | fetch(`${servers.url}${path}`, { | |
| 333 | headers: { host: `${slug}.${DOMAIN}`, accept: "*/*" }, | |
| 334 | redirect: "manual", | |
| 335 | }); | |
| 336 | ||
| 337 | test("an HTML page carries both Links, the app's own kept, and the manifest answers", async () => { | |
| 338 | const res = await at("notes", "/"); | |
| 339 | const link = res.headers.get("link") ?? ""; | |
| 340 | expect(link).toContain('</own.css>; rel="stylesheet"'); | |
| 341 | expect(link).toContain('</__forge/report>; rel="help"'); | |
| 342 | const manifest = linkTo(res, "alternate")!; | |
| 343 | expect(manifest).toBe(`${servers.url}/api/v1/here?path=%2Falice%2Fnotes`); | |
| 344 | expect(link).toContain(`<${manifest}>; rel="alternate"; type="application/json"`); | |
| 345 | const got = await fetch(manifest); | |
| 346 | expect(got.status).toBe(200); | |
| 347 | expect(((await got.json()) as any).here.kind).toBe("repository"); | |
| 348 | }); | |
| 349 | ||
| 350 | test("a private app and a JSON answer get neither", async () => { | |
| 351 | const priv = await at("diary", "/"); | |
| 352 | expect(priv.headers.get("link") ?? "").not.toContain("api/v1/here"); | |
| 353 | expect(priv.headers.get("link") ?? "").not.toContain("__forge/report"); | |
| 354 | const json = await at("notes", "/json"); | |
| 355 | expect(json.headers.get("link") ?? "").not.toContain("api/v1/here"); | |
| 356 | expect(json.headers.get("link") ?? "").not.toContain("__forge/report"); | |
| 357 | expect(linkTo(json, "alternate")).toBeUndefined(); | |
| 358 | expect(linkTo(json, "help")).toBeUndefined(); | |
| 359 | }); | |
| 360 | ||
| 361 | test("a nested host, which is its guest's to speak for, gets neither", async () => { | |
| 362 | const nested = await at("inner--notes", "/"); | |
| 363 | expect(nested.status).toBe(200); | |
| 364 | expect(linkTo(nested, "alternate")).toBeUndefined(); | |
| 365 | expect(linkTo(nested, "help")).toBeUndefined(); | |
| 366 | }); | |
| 367 | ||
| 368 | test("the forge's own 'not running' page carries both Links for a public app only", async () => { | |
| 369 | status.mockImplementation( | |
| 370 | () => ({ env: "production", state: "stopped" }) as EnvStatus, | |
| 371 | ); | |
| 372 | try { | |
| 373 | const down = await at("notes", "/"); | |
| 374 | expect(down.status).toBe(502); | |
| 375 | expect(linkTo(down, "help")).toBe("/__forge/report"); | |
| 376 | expect(linkTo(down, "alternate")).toBe( | |
| 377 | `${servers.url}/api/v1/here?path=%2Falice%2Fnotes`, | |
| 378 | ); | |
| 379 | const priv = await at("diary", "/"); | |
| 380 | expect(linkTo(priv, "alternate")).toBeUndefined(); | |
| 381 | } finally { | |
| 382 | status.mockImplementation( | |
| 383 | () => | |
| 384 | ({ | |
| 385 | env: "production", | |
| 386 | state: "running", | |
| 387 | port: backend.port, | |
| 388 | commitOid: "a".repeat(40), | |
| 389 | }) as EnvStatus, | |
| 390 | ); | |
| 391 | } | |
| 392 | }); | |
| 393 | }); |
new above
offline
Non-null assertion on getRepoById. Share the repo lookup with reportUrl, or return undefined and skip the Link when the repo is gone.