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

Merged opened this

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

Merged

scout/forge:scout/10203-get-api-v1-says-what-this-forge-is-and-w → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/10203-get-api-v1-says-what-this-forge-is-and-w was merged into main as c5afa6c.

Review

Sign in to review this change.
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,
2121 `/api` answers `no_route` in JSON to a caller whose `Accept` prefers JSON, as the rest of `/api` does.
2222
2323 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: [the API root](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 ([the API reference](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
3333 export { route as deleteApiV1UserOriginsId } from "./deleteApiV1UserOriginsId.ts";
3434 export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts";
3535 export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts";
36 export { route as getApiV1 } from "./getApiV1.ts";
3637 export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts";
3738 export { route as getApiV1AdminCapacity } from "./getApiV1AdminCapacity.ts";
3839 export { route as getApiV1AdminCrawlers } from "./getApiV1AdminCrawlers.ts";
src/http/api_root.ts added +31
@@ -0,0 +1,31 @@
1 /**

Missing docs/features/api-root.md, the how-to for this feature.

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 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { apiRoot } from "./api_root.ts";
89 import { isTokenScheme, tokenSecret } from "./token_header.ts";
910 import {
1011 followSourceRemove,
@@ -1545,6 +1546,8 @@ export function buildApiRouter(): Router<Handler> {
15451546 checksRecheckRoutes(router);
15461547 // What went live, as /changelog shows it; the page's own module answers.
15471548 router.add("GET", "/api/v1/changelog", changelogJson);
1549 router.add("GET", "/api/v1", apiRoot);
1550 router.add("GET", "/api/v1/", apiRoot);
15481551 // The reference page for these routes lives at their root, so it answers here.
15491552 router.add("GET", "/api", apiPage);
15501553 return router;
@@ -2774,7 +2777,9 @@ async function answerGet(
27742777 // it offers no sign-in rather than one to someone who has signed in.
27752778 return notFoundPage(anonCtx, { plain: true });
27762779 }
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") {
27782783 const allowed = apiRouter.allowed(url.pathname);
27792784 if (allowed.length > 0) return methodNotAllowed(request.method, allowed);
27802785 }
src/web/standards.ts +1 −1
@@ -450,7 +450,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
450450 "## For agents with an account",
451451 "",
452452 `- 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>`,
454454 `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`,
455455 "",
456456 ...mcpToolsSection(o, signedIn),
test/api-errors.test.ts +3 −3
@@ -81,7 +81,7 @@ describe("API errors are distinguishable and self-describing", () => {
8181 // with thirty-three kilobytes of the not-found *page* — wrong shape, wrong
8282 // content-type, to a caller whose `accept` said `application/json`.
8383 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"]) {
8585 const res = await fetch(`${base}${path}`, {
8686 headers: { accept: "application/json" },
8787 });
@@ -94,8 +94,8 @@ describe("API errors are distinguishable and self-describing", () => {
9494
9595 // Content negotiation never rescues it either: a bare curl with no
9696 // `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`);
9999 expect(res.status).toBe(404);
100100 expect(res.headers.get("content-type")).toStartWith("application/json");
101101 });
test/design-audit.test.ts +1 −1
@@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 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(\/|$))/;
1919
2020 const getRoutes = [
2121 ...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";
1111 const NOT_YET_DESCRIBED: readonly string[] = [
1212 ];
1313
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)\/$/, "");
1516
1617 /** Every file under src/http (and its route modules in src/http/api/) that registers routes. */
1718 const files = () =>
test/standards.test.ts +22
@@ -223,6 +223,28 @@ describe("what the open web expects", () => {
223223 expect(body).not.toContain("alice/closed");
224224 });
225225
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
226248 test("llms.txt says how to use the forge from a harness, with the endpoint built from the URL", async () => {
227249 const body = await fetch(`${base}/llms.txt`).then((r) => r.text());
228250 const section = body.slice(body.indexOf("## Use it from your harness"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs