Public: anyone can read this; the crew can change it.
Error hints and the API root name /contract/openapi.json directly, not the path that redirects to it #11106
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11104-error-hints-and-the-api-root-name-contra → main 3 commits fork
5 files
changed +44
−7
docs/features/api-root.md +1 −1
| @@ -13,7 +13,7 @@ curl -i https://forge.example/api/v1 | ||
| 13 | 13 | "name": "forge", |
| 14 | 14 | "version": "0.7.480", |
| 15 | 15 | "commit": "1bda42c9…", |
| 16 | "openapi": "https://forge.example/api/v1/openapi.json", | |
| 16 | "openapi": "https://forge.example/contract/openapi.json", | |
| 17 | 17 | "mcp": "https://forge.example/mcp", |
| 18 | 18 | "oauth": "https://forge.example/.well-known/oauth-protected-resource", |
| 19 | 19 | "llms": "https://forge.example/llms.txt", |
src/http/api_root.ts +1 −1
| @@ -16,7 +16,7 @@ export function apiRootBody(origin: string): Record<string, unknown> { | ||
| 16 | 16 | name: "forge", |
| 17 | 17 | version, |
| 18 | 18 | commit, |
| 19 | openapi: `${origin}/api/v1/openapi.json`, | |
| 19 | openapi: `${origin}/contract/openapi.json`, | |
| 20 | 20 | mcp: mcp.url, |
| 21 | 21 | oauth: mcp.oauth, |
| 22 | 22 | llms: `${origin}/llms.txt`, |
src/http/respond.ts +4 −4
| @@ -48,18 +48,18 @@ export function json( | ||
| 48 | 48 | const HINTS: Record<number, string> = { |
| 49 | 49 | 401: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call.", |
| 50 | 50 | 403: "Use a token or account with the scope or role this route needs (the message names it), or ask the owner for it.", |
| 51 | 404: "Check the path and ids against GET /api/v1/openapi.json, or list the parent collection to find the right one.", | |
| 51 | 404: "Check the path and ids against GET /contract/openapi.json, or list the parent collection to find the right one.", | |
| 52 | 52 | 409: "The resource changed or already exists: read its current state with a GET, then repeat the call with what you see.", |
| 53 | 422: "A field failed validation: fix the one the message names (see the route's body in GET /api/v1/openapi.json) and send again.", | |
| 53 | 422: "A field failed validation: fix the one the message names (see the route's body in GET /contract/openapi.json) and send again.", | |
| 54 | 54 | }; |
| 55 | 55 | const HINTS_BY_CODE: Record<string, string> = { |
| 56 | 56 | held: "This pull is on hold: ask the person who placed the hold to release it, then repeat the call.", |
| 57 | 57 | review_required: "This pull needs an approving review first: ask a reviewer, read GET its reviews to see what is missing, then repeat the call.", |
| 58 | 58 | checks_required: "The pull's head needs a passing check or deploy first: push a fix or wait for the check, then repeat the call.", |
| 59 | invalid: "A field failed validation: fix the field the message names (see the route's body in GET /api/v1/openapi.json) and send the whole body again.", | |
| 59 | invalid: "A field failed validation: fix the field the message names (see the route's body in GET /contract/openapi.json) and send the whole body again.", | |
| 60 | 60 | not_admin: "This route is for administrators only: use an administrator's token; a different scope will not help.", |
| 61 | 61 | forbidden: "Your token's scope or your role on this resource does not allow this: a read-only (`ro`) token cannot write, so mint an `rw` token, or ask an owner for the role the message names.", |
| 62 | no_route: "No route matches this path: pick one from GET /api/v1/openapi.json and check the spelling.", | |
| 62 | no_route: "No route matches this path: pick one from GET /contract/openapi.json and check the spelling.", | |
| 63 | 63 | }; |
| 64 | 64 | |
| 65 | 65 | /** Every 401, 403, 404, 409 and 422 error body carries `hint`; a site's own is kept. */ |
test/api-root-contract-link.test.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** #11104: the API root and the error hints name /contract/openapi.json, which answers 200 itself, not the path that redirects. */ | |
| 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 | import { quietLogs } from "./helpers/globals.ts"; | |
| 9 | ||
| 10 | quietLogs(); | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let servers: Servers; | |
| 14 | beforeAll(async () => { | |
| 15 | dataDir = await mkdtemp(join(tmpdir(), "forge-root-contract-")); | |
| 16 | servers = await serve(createApp({ dataDir, port: 0 })); | |
| 17 | }); | |
| 18 | afterAll(async () => { | |
| 19 | await servers.stop?.(); | |
| 20 | await rm(dataDir, { recursive: true, force: true }); | |
| 21 | }); | |
| 22 | ||
| 23 | test("the root's openapi link answers 200 with no redirect", async () => { | |
| 24 | const root = (await (await fetch(`${servers.url}/api/v1`)).json()) as { openapi: string }; | |
| 25 | expect(new URL(root.openapi).pathname).toBe("/contract/openapi.json"); | |
| 26 | const res = await fetch(`${servers.url}/contract/openapi.json`, { redirect: "manual" }); | |
| 27 | expect(res.status).toBe(200); | |
| 28 | }); | |
| 29 | ||
| 30 | test("the 404 hint names the contract path, and the old path still redirects", async () => { | |
| 31 | const res = await fetch(`${servers.url}/api/v1/nope-nothing`, { headers: { accept: "application/json" } }); | |
| 32 | const body = JSON.stringify(await res.json()); | |
| 33 | expect(body).toContain("/contract/openapi.json"); | |
| 34 | expect(body).not.toContain("/api/v1/openapi.json"); | |
| 35 | const old = await fetch(`${servers.url}/api/v1/openapi.json`, { redirect: "manual" }); | |
| 36 | expect(old.status).toBe(308); | |
| 37 | }); |
test/standards.test.ts +1 −1
| @@ -232,7 +232,7 @@ describe("what the open web expects", () => { | ||
| 232 | 232 | expect(res.headers.get("content-security-policy")).not.toBeNull(); |
| 233 | 233 | const j = (await res.json()) as Record<string, unknown>; |
| 234 | 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`); | |
| 235 | expect(j["openapi"]).toBe(`${base}/contract/openapi.json`); | |
| 236 | 236 | expect(j["mcp"]).toBe(`${base}/mcp`); |
| 237 | 237 | expect(j["oauth"]).toBe(`${base}/.well-known/oauth-protected-resource`); |
| 238 | 238 | expect(j["llms"]).toBe(`${base}/llms.txt`); |