Public: anyone can read this; the crew can change it.
GET /openapi.json at the root redirects to /contract/openapi.json, as /api/v1/openapi.json does #8414
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8330-get-openapi-json-at-the-root-redirects-t → main 3 commits fork
6 files
changed +37
−1
src/core/reserved.ts +1
| @@ -98,6 +98,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 98 | 98 | "apple-touch-icon.png", |
| 99 | 99 | "apple-touch-icon-precomposed.png", |
| 100 | 100 | "llms.txt", |
| 101 | "openapi.json", | |
| 101 | 102 | "runs", |
| 102 | 103 | "search", |
| 103 | 104 | "settings", |
src/http/server.ts +6
| @@ -726,6 +726,12 @@ export function buildRouter(): Router<Handler> { | ||
| 726 | 726 | router.add("GET", "/llms.txt", llmsTxt); |
| 727 | 727 | router.add("GET", "/.well-known/change-password", changePassword); |
| 728 | 728 | router.add("GET", "/robots.txt", robotsTxt); |
| 729 | // The conventional path an agent guesses, answered as /api/v1/openapi.json is. | |
| 730 | router.add( | |
| 731 | "GET", | |
| 732 | "/openapi.json", | |
| 733 | () => new Response(null, { status: 308, headers: { location: "/contract/openapi.json", "cache-control": "public, max-age=86400" } }), | |
| 734 | ); | |
| 729 | 735 | router.add("GET", "/releases.json", releasesJson); |
| 730 | 736 | router.add("GET", "/sitemap.xml", sitemapXml); |
| 731 | 737 | router.add("GET", "/manifest.webmanifest", webManifest); |
test/design-audit.test.ts +1 −1
| @@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | const NOT_PAGES = |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/forge-looks.test.ts +1
| @@ -22,6 +22,7 @@ quietLogs(); | ||
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | 23 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 24 | 24 | "/robots.txt": "plain text for crawlers", |
| 25 | "/openapi.json": "a redirect to the contract document", | |
| 25 | 26 | "/favicon.ico": "the tab icon, an image", |
| 26 | 27 | "/releases.json": "JSON feed of /releases", |
| 27 | 28 | "/sitemap.xml": "XML for crawlers", |
test/root-openapi.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** #8330: /openapi.json at the root redirects to the contract, as /api/v1/openapi.json does. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | ||
| 9 | let dataDir: string; | |
| 10 | let servers: Servers; | |
| 11 | ||
| 12 | beforeAll(async () => { | |
| 13 | dataDir = await mkdtemp(join(tmpdir(), "forge-rootopenapi-")); | |
| 14 | servers = await serve(createApp({ dataDir, port: 0 })); | |
| 15 | }); | |
| 16 | afterAll(async () => { | |
| 17 | servers.stop(); | |
| 18 | await rm(dataDir, { recursive: true, force: true }); | |
| 19 | }); | |
| 20 | ||
| 21 | test("GET and HEAD /openapi.json answer 308 to the contract", async () => { | |
| 22 | for (const method of ["GET", "HEAD"]) { | |
| 23 | const res = await fetch(`${servers.url}/openapi.json`, { method, redirect: "manual" }); | |
| 24 | expect(res.status).toBe(308); | |
| 25 | expect(res.headers.get("location")).toBe("/contract/openapi.json"); | |
| 26 | } | |
| 27 | }); |
test/server-timing-sweep.test.ts +1
| @@ -45,6 +45,7 @@ const REDIRECTS = new Set([ | ||
| 45 | 45 | "/big", |
| 46 | 46 | "/flow", |
| 47 | 47 | "/login", |
| 48 | "/openapi.json", | |
| 48 | 49 | "/settings/origins/callback", |
| 49 | 50 | "/settings/resilience", |
| 50 | 51 | "/sign-in", |
new above
offline