Public: anyone can read this; the crew can change it.
An app page's Link header says which JSON manifest is the app's and which is its repository's on the forge #8298
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8288-an-app-page-s-link-header-says-which-jso → main 1 commit fork
3 files
changed +117
−2
src/apps/proxy.ts +7 −1
| @@ -787,6 +787,9 @@ const reportOf = (runtime: AppsRuntime, repo: Repo): URL => | ||
| 787 | 787 | const REPORT_LINK = |
| 788 | 788 | `</__forge/report>; rel="help"; title="${REPORT_LINK_TITLE}"`; |
| 789 | 789 | |
| 790 | /** Names the forge's manifest, so it is told apart from an app's own alternate. */ | |
| 791 | export const REPO_LINK_TITLE = "this app's repository on its forge"; | |
| 792 | ||
| 790 | 793 | /** |
| 791 | 794 | * The `Link` values a public app's pages carry: the report door and the |
| 792 | 795 | * forge's role-aware manifest for the app's repository, so an agent that |
| @@ -798,7 +801,10 @@ export function discoveryLinks(runtime: AppsRuntime, target: AppTarget): string[ | ||
| 798 | 801 | if (repo === undefined) return []; |
| 799 | 802 | const here = new URL("/api/v1/here", runtime.options.forgeUrl); |
| 800 | 803 | here.search = `?path=${encodeURIComponent(`/${repo.ownerName}/${repo.name}`)}`; |
| 801 | return [REPORT_LINK, `<${here.href}>; rel="alternate"; type="application/json"`]; | |
| 804 | return [ | |
| 805 | REPORT_LINK, | |
| 806 | `<${here.href}>; rel="alternate"; type="application/json"; title="${REPO_LINK_TITLE}"`, | |
| 807 | ]; | |
| 802 | 808 | } |
| 803 | 809 | |
| 804 | 810 | const isHtml = (headers: Headers): boolean => |
src/web/standards.ts +1 −1
| @@ -364,7 +364,7 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 364 | 364 | "", |
| 365 | 365 | "## What you can do on any page", |
| 366 | 366 | "", |
| 367 | `- GET ${o}/api/v1/here?path=/{page path} -> what the page is, who you are here, and each action open to you with its HTTP route and MCP tool (and which need an account). Every page links to it: rel="alternate" type="application/json". MCP: the tool "here" with the page URL.`, | |
| 367 | `- GET ${o}/api/v1/here?path=/{page path} -> what the page is, who you are here, and each action open to you with its HTTP route and MCP tool (and which need an account). Every page links to it: rel="alternate" type="application/json". On an app's own pages the forge's link is the one titled "this app's repository on its forge"; follow that one. MCP: the tool "here" with the page URL.`, | |
| 368 | 368 | "", |
| 369 | 369 | ...(tell === undefined |
| 370 | 370 | ? [] |
test/apps-discovery-links.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * An app page's `Link` header carries the app's own alternate manifest and | |
| 3 | * the forge's for its repository (#8288); only the forge's is titled, so an | |
| 4 | * agent can tell which describes what it may do with the app's repository. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { REPO_LINK_TITLE } from "../src/apps/proxy.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | describe("a public app's own alternate link and the forge's are told apart", () => { | |
| 19 | quietLogs(); | |
| 20 | const DOMAIN = "apps.test"; | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let runtime: AppsRuntime; | |
| 25 | let backend: ReturnType<typeof Bun.serve>; | |
| 26 | let status: ReturnType<typeof spyOn>; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | backend = Bun.serve({ | |
| 30 | port: 0, | |
| 31 | fetch(request) { | |
| 32 | const path = new URL(request.url).pathname; | |
| 33 | if (path === "/json") return Response.json({ a: 1 }); | |
| 34 | return new Response("<html><body>hi</body></html>", { | |
| 35 | headers: { | |
| 36 | "content-type": "text/html; charset=utf-8", | |
| 37 | link: '</own.json>; rel="alternate"; type="application/json"', | |
| 38 | }, | |
| 39 | }); | |
| 40 | }, | |
| 41 | }); | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-manifest-link-")); | |
| 43 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 44 | servers = await serve(app); | |
| 45 | runtime = new AppsRuntime(app, { | |
| 46 | domain: DOMAIN, | |
| 47 | scheme: "http", | |
| 48 | publicPort: servers.port, | |
| 49 | forgeUrl: servers.url, | |
| 50 | concurrency: 1, | |
| 51 | }); | |
| 52 | await runtime.start(); | |
| 53 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 54 | () => | |
| 55 | ({ | |
| 56 | env: "production", | |
| 57 | state: "running", | |
| 58 | port: backend.port, | |
| 59 | commitOid: "a".repeat(40), | |
| 60 | }) as EnvStatus, | |
| 61 | ); | |
| 62 | const a = await createUser(app.db, { | |
| 63 | origin: "seed", | |
| 64 | trusted: true, | |
| 65 | username: "alice", | |
| 66 | email: "[email protected]", | |
| 67 | password: "a-good-passphrase", | |
| 68 | adminIfFirst: true, | |
| 69 | }); | |
| 70 | if (!a.ok) throw new Error(a.error); | |
| 71 | for (const [name, visibility] of [ | |
| 72 | ["notes", "public"], | |
| 73 | ["diary", "private"], | |
| 74 | ] as const) { | |
| 75 | const made = await newApp(app, a.value, { | |
| 76 | name, | |
| 77 | description: "Words", | |
| 78 | visibility, | |
| 79 | }); | |
| 80 | if (!made.ok) throw new Error(String(made.error)); | |
| 81 | } | |
| 82 | }, 120_000); | |
| 83 | afterAll(async () => { | |
| 84 | status.mockRestore(); | |
| 85 | await runtime.stop(); | |
| 86 | servers.stop(); | |
| 87 | backend.stop(true); | |
| 88 | app.close(); | |
| 89 | await rm(dataDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("both alternates survive and only the forge's is titled", async () => { | |
| 93 | const res = await fetch(`${servers.url}/`, { | |
| 94 | headers: { host: `notes.${DOMAIN}`, accept: "*/*" }, | |
| 95 | }); | |
| 96 | const link = res.headers.get("link") ?? ""; | |
| 97 | expect(REPO_LINK_TITLE).toBe("this app's repository on its forge"); | |
| 98 | expect(link).toContain('</own.json>; rel="alternate"; type="application/json"'); | |
| 99 | expect(link).not.toContain('</own.json>; rel="alternate"; type="application/json"; title'); | |
| 100 | expect(link).toContain( | |
| 101 | `<${servers.url}/api/v1/here?path=%2Falice%2Fnotes>; rel="alternate"; type="application/json"; title="${REPO_LINK_TITLE}"`, | |
| 102 | ); | |
| 103 | }); | |
| 104 | ||
| 105 | test("llms.txt names the title", async () => { | |
| 106 | const txt = await (await fetch(`${servers.url}/llms.txt`)).text(); | |
| 107 | expect(txt).toContain(`"${REPO_LINK_TITLE}"`); | |
| 108 | }); | |
| 109 | }); |
new above
offline