Public: anyone can read this; the crew can change it.
GET /api/v1 says what this forge is and where its API, MCP, OAuth and docs live #10207
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10203-get-api-v1-says-what-this-forge-is-and-w → main 4 commits fork 1 thread · 1 unresolved
11 files
changed +109
−7
docs/features/api-reference.md +2
| @@ -21,3 +21,5 @@ path parameter runs it as the person at the terminal and shows the status, time, | ||
| 21 | 21 | `/api` answers `no_route` in JSON to a caller whose `Accept` prefers JSON, as the rest of `/api` does. |
| 22 | 22 | |
| 23 | 23 | A list the contract gives a `cursor` pages by it: `?page=` or `?offset=` on one is a 400 `invalid_query` ("This list pages by cursor: pass next_cursor as ?cursor="), answered by one guard in `src/http/cursor_lists.ts` before any handler runs, and named in the document as the route's 400. |
| 24 | ||
| 25 | The root, `GET /api/v1`, names the OpenAPI, MCP, OAuth and llms.txt URLs with no account: [](api-root.md). |
docs/features/api-root.md added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | # The API root | |
| 2 | ||
| 3 | `GET /api/v1` (and `/api/v1/`) is the API's front door: an agent that starts there, with no account, learns what | |
| 4 | this forge is and where everything else lives. HEAD answers too. It is public, read-only and small | |
| 5 | (`src/http/api_root.ts`). | |
| 6 | ||
| 7 | ||
| 8 | curl -i https://forge.example/api/v1 | |
| 9 | ||
| 10 | ||
| 11 | json | |
| 12 | { | |
| 13 | "name": "forge", | |
| 14 | "version": "0.7.480", | |
| 15 | "commit": "1bda42c9…", | |
| 16 | "openapi": "https://forge.example/api/v1/openapi.json", | |
| 17 | "mcp": "https://forge.example/mcp", | |
| 18 | "oauth": "https://forge.example/.well-known/oauth-protected-resource", | |
| 19 | "llms": "https://forge.example/llms.txt", | |
| 20 | "here": "https://forge.example/api/v1/here" | |
| 21 | } | |
| 22 | ||
| 23 | ||
| 24 | - Every URL is absolute and on the origin the request came to, so a preview points at itself. | |
| 25 | - `version` and `commit` are what is running (`running()` in `src/kernel/version.ts`); `commit` is `null` when | |
| 26 | the build is unstamped. | |
| 27 | - The reply carries the usual security headers and `cache-control: no-cache`. | |
| 28 | - It is a read: `POST /api/v1` stays a 404 `no_route`. | |
| 29 | ||
| 30 | `/llms.txt` mentions the root in its API line. The operation is described in the OpenAPI document and on `/api` | |
| 31 | ([](api-reference.md)). |
src/contract/openapi/routes/getApiV1.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1", | |
| 6 | summary: "The API's front door, for anyone and with no account (also at /api/v1/, and HEAD): { name, version, commit, openapi, mcp, oauth, llms, here }, each a URL on the request's own origin; a POST answers 404 no_route", | |
| 7 | auth: false, | |
| 8 | refusals: {}, | |
| 9 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -33,6 +33,7 @@ export { route as deleteApiV1UserOriginAppsId } from "./deleteApiV1UserOriginApp | ||
| 33 | 33 | export { route as deleteApiV1UserOriginsId } from "./deleteApiV1UserOriginsId.ts"; |
| 34 | 34 | export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts"; |
| 35 | 35 | export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts"; |
| 36 | export { route as getApiV1 } from "./getApiV1.ts"; | |
| 36 | 37 | export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts"; |
| 37 | 38 | export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts"; |
| 38 | 39 | export { route as getApiV1AdminCrawlers } from "./getApiV1AdminCrawlers.ts"; |
src/http/api_root.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * GET /api/v1 — the API's front door. An agent that starts here with no | |
| 3 | * account learns what this forge is and where its OpenAPI, MCP, OAuth, | |
| 4 | * llms.txt and page manifest live. Public, no state, absolute URLs on the | |
| 5 | * request's own origin. | |
| 6 | */ | |
| 7 | ||
| 8 | import { mcpPointers } from "../core/mcp_card.ts"; | |
| 9 | import { running } from "../kernel/version.ts"; | |
| 10 | import type { Ctx } from "./context.ts"; | |
| 11 | ||
| 12 | export function apiRootBody(origin: string): Record<string, unknown> { | |
| 13 | const { version, commit } = running(); | |
| 14 | const mcp = mcpPointers(origin); | |
| 15 | return { | |
| 16 | name: "forge", | |
| 17 | version, | |
| 18 | commit, | |
| 19 | openapi: `${origin}/api/v1/openapi.json`, | |
| 20 | mcp: mcp.url, | |
| 21 | oauth: mcp.oauth, | |
| 22 | llms: `${origin}/llms.txt`, | |
| 23 | here: `${origin}/api/v1/here`, | |
| 24 | }; | |
| 25 | } | |
| 26 | ||
| 27 | export function apiRoot(ctx: Ctx): Response { | |
| 28 | return new Response(JSON.stringify(apiRootBody(ctx.url.origin)), { | |
| 29 | headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-cache" }, | |
| 30 | }); | |
| 31 | } |
src/http/server.ts +6 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { apiRoot } from "./api_root.ts"; | |
| 8 | 9 | import { isTokenScheme, tokenSecret } from "./token_header.ts"; |
| 9 | 10 | import { |
| 10 | 11 | followSourceRemove, |
| @@ -1545,6 +1546,8 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1545 | 1546 | checksRecheckRoutes(router); |
| 1546 | 1547 | // What went live, as /changelog shows it; the page's own module answers. |
| 1547 | 1548 | router.add("GET", "/api/v1/changelog", changelogJson); |
| 1549 | router.add("GET", "/api/v1", apiRoot); | |
| 1550 | router.add("GET", "/api/v1/", apiRoot); | |
| 1548 | 1551 | // The reference page for these routes lives at their root, so it answers here. |
| 1549 | 1552 | router.add("GET", "/api", apiPage); |
| 1550 | 1553 | return router; |
| @@ -2774,7 +2777,9 @@ async function answerGet( | ||
| 2774 | 2777 | // it offers no sign-in rather than one to someone who has signed in. |
| 2775 | 2778 | return notFoundPage(anonCtx, { plain: true }); |
| 2776 | 2779 | } |
| 2777 | if (isApiPath && request.method !== "OPTIONS") { | |
| 2780 | // The API root is read-only and answers a write as it always did: no route. | |
| 2781 | const apiRootPath = url.pathname === "/api/v1" || url.pathname === "/api/v1/"; | |
| 2782 | if (isApiPath && !apiRootPath && request.method !== "OPTIONS") { | |
| 2778 | 2783 | const allowed = apiRouter.allowed(url.pathname); |
| 2779 | 2784 | if (allowed.length > 0) return methodNotAllowed(request.method, allowed); |
| 2780 | 2785 | } |
src/web/standards.ts +1 −1
| @@ -450,7 +450,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 450 | 450 | "## For agents with an account", |
| 451 | 451 | "", |
| 452 | 452 | `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}/.well-known/mcp.json)`, |
| 453 | `- API: ${o}/api/v1, Authorization: Bearer <token>`, | |
| 453 | `- API: ${o}/api/v1 (the root lists the other URLs), Authorization: Bearer <token>`, | |
| 454 | 454 | `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`, |
| 455 | 455 | "", |
| 456 | 456 | ...mcpToolsSection(o, signedIn), |
test/api-errors.test.ts +3 −3
| @@ -81,7 +81,7 @@ describe("API errors are distinguishable and self-describing", () => { | ||
| 81 | 81 | // with thirty-three kilobytes of the not-found *page* — wrong shape, wrong |
| 82 | 82 | // content-type, to a caller whose `accept` said `application/json`. |
| 83 | 83 | test("/api and /api/v1 are JSON 404s, not the studio's HTML page", async () => { |
| 84 | for (const path of ["/api", "/api/v1", "/api/v2/anything"]) { | |
| 84 | for (const path of ["/api", "/api/v2", "/api/v2/anything"]) { | |
| 85 | 85 | const res = await fetch(`${base}${path}`, { |
| 86 | 86 | headers: { accept: "application/json" }, |
| 87 | 87 | }); |
| @@ -94,8 +94,8 @@ describe("API errors are distinguishable and self-describing", () => { | ||
| 94 | 94 | |
| 95 | 95 | // Content negotiation never rescues it either: a bare curl with no |
| 96 | 96 | // `accept` header at all must still get JSON under `/api/`. |
| 97 | test("/api/v1 is JSON even with no accept header", async () => { | |
| 98 | const res = await fetch(`${base}/api/v1`); | |
| 97 | test("/api/v2 is JSON even with no accept header", async () => { | |
| 98 | const res = await fetch(`${base}/api/v2`); | |
| 99 | 99 | expect(res.status).toBe(404); |
| 100 | 100 | expect(res.headers.get("content-type")).toStartWith("application/json"); |
| 101 | 101 | }); |
test/design-audit.test.ts +1 −1
| @@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | const NOT_PAGES = |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|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$|changelog\.atom$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|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$|changelog\.atom$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/openapi-coverage.test.ts +2 −1
| @@ -11,7 +11,8 @@ import { ROUTES } from "../src/contract/openapi.ts"; | ||
| 11 | 11 | const NOT_YET_DESCRIBED: readonly string[] = [ |
| 12 | 12 | ]; |
| 13 | 13 | |
| 14 | const norm = (path: string) => path.replace(/[:*]\w+/g, "{}").replace(/\{\w+\}/g, "{}"); | |
| 14 | // "/api/v1/" is the same operation as "/api/v1": a trailing slash is not a second row. | |
| 15 | const norm = (path: string) => path.replace(/[:*]\w+/g, "{}").replace(/\{\w+\}/g, "{}").replace(/(?<=\w)\/$/, ""); | |
| 15 | 16 | |
| 16 | 17 | /** Every file under src/http (and its route modules in src/http/api/) that registers routes. */ |
| 17 | 18 | const files = () => |
test/standards.test.ts +22
| @@ -223,6 +223,28 @@ describe("what the open web expects", () => { | ||
| 223 | 223 | expect(body).not.toContain("alice/closed"); |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | test("GET /api/v1 names the forge and where its API, MCP, OAuth and docs live; POST is still no_route", async () => { | |
| 227 | for (const path of ["/api/v1", "/api/v1/"]) { | |
| 228 | const res = await fetch(`${base}${path}`); | |
| 229 | expect(res.status).toBe(200); | |
| 230 | expect(res.headers.get("cache-control")).toBe("no-cache"); | |
| 231 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 232 | expect(res.headers.get("content-security-policy")).not.toBeNull(); | |
| 233 | const j = (await res.json()) as Record<string, unknown>; | |
| 234 | expect(Object.keys(j).sort()).toEqual(["commit", "here", "llms", "mcp", "name", "oauth", "openapi", "version"]); | |
| 235 | expect(j["openapi"]).toBe(`${base}/api/v1/openapi.json`); | |
| 236 | expect(j["mcp"]).toBe(`${base}/mcp`); | |
| 237 | expect(j["oauth"]).toBe(`${base}/.well-known/oauth-protected-resource`); | |
| 238 | expect(j["llms"]).toBe(`${base}/llms.txt`); | |
| 239 | expect(j["here"]).toBe(`${base}/api/v1/here`); | |
| 240 | } | |
| 241 | expect((await fetch(`${base}/api/v1`, { method: "HEAD" })).status).toBe(200); | |
| 242 | const post = await fetch(`${base}/api/v1`, { method: "POST", headers: { origin: base } }); | |
| 243 | expect(post.status).toBe(404); | |
| 244 | expect(((await post.json()) as { error?: string }).error).toBe("no_route"); | |
| 245 | expect(await fetch(`${base}/llms.txt`).then((r) => r.text())).toContain(`${base}/api/v1 (the root`); | |
| 246 | }); | |
| 247 | ||
| 226 | 248 | test("llms.txt says how to use the forge from a harness, with the endpoint built from the URL", async () => { |
| 227 | 249 | const body = await fetch(`${base}/llms.txt`).then((r) => r.text()); |
| 228 | 250 | const section = body.slice(body.indexOf("## Use it from your harness")); |
Missing docs/features/api-root.md, the how-to for this feature.