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

Merged opened this

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

Merged

torque/forge:torque/7861-a-public-app-s-pages-carry-a-link-to-the → main 2 commits fork 4 threads · 4 unresolved

  • Merged. torque/7861-a-public-app-s-pages-carry-a-link-to-the was merged into main as 4c66bb1.

Review

Sign in to review this change.
4 files changed +197 −21
README.md +6
@@ -364,6 +364,12 @@ work here.
364364
365365 ## What works today
366366
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
367373 Open an image or SVG in a repository and it is drawn, fit to the pane. Press
368374 **Actual size** (or click the picture) for 100%; the pane scrolls or drags to
369375 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
917917 form (STANDARDS.md, "Reports from outside") with the page they came from,
918918 and is answered without waking the app, since the app being down is the
919919 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
921923 template's footer links to it. A private app or repository offers none of
922924 this.
923925
src/apps/proxy.ts +31 −10
@@ -753,20 +753,40 @@ function admit(
753753 * public. A private one offers nothing, and a nested host is its guest's.
754754 */
755755 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 {
756762 if (target.nested || target.app.access !== "public") return undefined;
757763 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;
764767 }
765768
769 const reportOf = (runtime: AppsRuntime, repo: Repo): URL =>
770 new URL(`/${repo.ownerName}/${repo.name}/report`, runtime.options.forgeUrl);
771
766772 /** The link relation a public app's pages carry to `/__forge/report`. */
767773 const REPORT_LINK =
768774 `</__forge/report>; rel="help"; title="${REPORT_LINK_TITLE}"`;
769775
776 /**
outdated

Non-null assertion on getRepoById. Share the repo lookup with reportUrl, or return undefined and skip the Link when the repo is gone.

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
770790 const isHtml = (headers: Headers): boolean =>
771791 (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html");
772792
@@ -1068,8 +1088,8 @@ export async function proxyApp(
10681088 for (const name of HOP_BY_HOP) out.delete(name);
10691089 // An agent reading a public app's page learns where to report a problem
10701090 // 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);
10731093 const eventStream =
10741094 response.body !== null &&
10751095 (response.headers.get("content-type") ?? "")
@@ -1526,7 +1546,8 @@ function unavailable(
15261546 : gone
15271547 ? "This preview is gone: its pull was closed, and its data with it."
15281548 : "Nothing is running here yet.");
1529 const report = reportUrl(runtime, target) !== undefined;
1549 const links = discoveryLinks(runtime, target);
1550 const report = links.length > 0;
15301551 const runLink =
15311552 status?.runId === undefined
15321553 ? ""
@@ -1555,7 +1576,7 @@ ${report ? '<p><a href="/__forge/report">Report a problem</a> to the people who
15551576 "content-type": "text/html; charset=utf-8",
15561577 "cache-control": "no-store",
15571578 ...(deploying ? { "retry-after": "3" } : {}),
1558 ...(report ? { link: REPORT_LINK } : {}),
1579 ...(report ? { link: links.join(", ") } : {}),
15591580 },
15601581 });
15611582 }
test/report-discovery.test.ts +157 −10
@@ -5,17 +5,28 @@
55 * and each refusal it meets on the way. A private repository gives it
66 * nothing to follow.
77 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
99 import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 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";
1215 import { createRepo } from "../src/actions/repos.ts";
1316 import { createApp, type App } from "../src/app.ts";
1417 import { createToken } from "../src/core/tokens.ts";
1518 import { createUser } from "../src/core/users.ts";
1619 import { serve, type Servers } from "../src/http/server.ts";
1720
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
1828 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.

1930 let dataDir: string;
2031 let app: App;
2132 let servers: Servers;
@@ -29,7 +40,9 @@ describe("an outside agent finds the report path unaided", () => {
2940 app = createApp({ dataDir, port: 0 });
3041 servers = await serve(app);
3142 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,
3346 username: "alice",
3447 email: "[email protected]",
3548 password: "a-good-passphrase",
@@ -62,13 +75,6 @@ describe("an outside agent finds the report path unaided", () => {
6275 .get() as { n: number }
6376 ).n;
6477
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
7278 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.

7379 const security = await (
7480 await fetch(`${base}/.well-known/security.txt`)
@@ -126,7 +132,10 @@ describe("an outside agent finds the report path unaided", () => {
126132 const filed = await fetch(hint.report.api, {
127133 method: "POST",
128134 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 }),
130139 });
131140 expect(filed.status).toBe(202);
132141 expect(waiting()).toBe(before + 1);
@@ -244,3 +253,141 @@ describe("an outside agent finds the report path unaided", () => {
244253 expect(((await signed.json()) as any).result).toEqual({});
245254 });
246255 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs