Public: anyone can read this; the crew can change it.
The OpenAPI description shows an anonymous reader only the routes it may call; admin routes appear only to an admin #11290
Check · Review · Land · Live done — Landed and live in production.
`GET /contract/openapi.json` (and `/openapi.json`, `/api/v1/openapi.json`) now lists only the routes the caller's role may call. A request with no credential gets the `x-role: anonymous` rows, a member's token or session adds the `member` rows, and a site admin gets all three. Before this, anyone could read all ~410 rows, 80 of them admin-only.
shipped production is serving it deploy a5a2512
GET /contract/openapi.json (and /openapi.json, /api/v1/openapi.json) now lists only the routes the caller's role may call. A request with no credential gets the x-role: anonymous rows, a member's token or session adds the member rows, and a site admin gets all three. Before this, anyone could read all ~410 rows, 80 of them admin-only.
What changed
src/contract/openapi.ts:openapi()takes a role and keeps only the rows that role may call. The tag list only includes tags that still have rows, so an anonymous reader doesn't see tags that only admin rows carry.src/contract/served.ts:
- The new roleOf() works out the role from the request's signed-in user.
- The cache is keyed by (version, origin, role) for the whole document and by (version, origin, role, tag) for ?tag= slices. Each limit is three times what it was, one share per role.
- The ETag hashes the role's own text, so it differs per role and an admin's tag never gets a 304 for an anonymous request.
- Every response carries vary: accept-encoding, authorization, cookie. Only the anonymous answer is cache-control: public; the other two are private.
docs/MCP.md: the Contract entry says the document is cut to the caller's role.
Tests
- New
test/contract-openapi-roles.test.tsasks as anonymous, member and admin. It checks the row counts for each role, fails if any admin row or admin-only tag reaches the first two, and checks the per-role ETags, the 304, the cache headers and?tag=slices per role. All three of its tests fail against the old code. - I changed 15 existing test files whose expectations were right before and wrong now:
- In test/contract-openapi-tags.test.ts, a request with no credential is now compared with the anonymous cut instead of the whole document.
- 14 suites fetched the document anonymously to check that a member or admin route is described. They now ask as a site admin through a new helper, test/helpers/openapi-doc.ts. The mcp test that checks the /api/v1/openapi.json redirect is unchanged.
Not done
- The
/apipage (src/web/pages/api.ts) and the terminal's API screen still callopenapiTextwith its default role,admin, so/apistill shows every row to anyone. Cutting it per role needs its own per-role cache; that's a follow-up issue. - No new MCP tool.
Checks run
bun scripts/gate.ts,bun x tsc --noEmitandbun test test/mcp-size.test.tspass.bun scripts/fast.tsnamed 1,848 files, over its budget, so I picked the tests myself: every test file that mentions the OpenAPI document, 943 tests, all passing.- I didn't run
bun scripts/design-lint.ts, since nothing undersrc/web/changed.
To try it: curl $ORIGIN/contract/openapi.json | jq '[.paths[][]."x-role"] | unique' should return ["anonymous"]. The same request with an admin's authorization: Bearer … header returns all three roles.
🤖 Generated with Claude Code
The cut works on /contract/openapi.json, but the same document still leaks through two other doors, so the admin routes are not actually hidden from an anonymous reader.
Fix first: src/web/pages/api.ts:32 and src/tui/screens/api.ts:97 call openapiText with the default role admin. The public /api page (200 to anonymous on the preview) and the terminal screen therefore show every admin row to anyone. Pass roleOf(ctx.auth) on the page and the signed-in user's role in the TUI (a guest gets anonymous). Deferring this to a follow-up leaves the leak the issue exists to close, and the terminal must gate reads exactly as the page does. Add a test that /api as anonymous does not contain an admin path.
Also: the default role = "admin" on openapi/openapiText/openapiSliceText is a fail-open default; that is how those two callers leaked. Make role required (tests that want everything pass "admin" explicitly).
On the preview, a request carrying the forge token still returned public, max-age=300 (anonymous cut); confirm a member/admin bearer token really reaches roleOf with ctx.auth.user set, with a real HTTP test using a token (the new tests only fake ctx.auth).
Boxes 1–5 all need the above before I tick them; box 1 and 5 in particular are not true while /api shows every row.
The OpenAPI document is now cut to the reader's role wherever it is shown, not only at /contract/openapi.json. A signed-out reader sees only the routes open to anyone, a member also sees member routes, and admin routes appear only to an admin.
What changed
- No default role.
openapi,openapiTextandopenapiSliceText(insrc/contract/openapi.tsandsrc/contract/served.ts) now take the role as a required argument, so every caller has to choose one. Before, the default wasadmin, which is how the next two callers leaked every row. Tests that want the whole document now pass"admin"explicitly; that is most of the files in this diff. - The
/apipage (src/web/pages/api.ts) now reads the document asroleOf(ctx.auth). Its cache keeps one copy per role, so the three roles don't overwrite each other's cache entry. - The terminal's API screen (
src/tui/screens/api.ts) now reads the document asroleOf(session). A guest gets the anonymous rows, the same as a signed-out page.
Tests
test/api-reference.test.ts:
- /api signed out lists every anonymous route and no admin or member route.
- The "every route appears" check now signs in as an admin.
- The terminal screen counts only the anonymous operations for a guest and every operation for an admin.
test/contract-openapi-roles.test.ts: real HTTP requests with minted bearer tokens, not a fakedctx.auth.
- A member's token gets private caching and the anonymous plus member rows.
- An admin's token gets every row.
- With no token, the answer is public and has only the anonymous rows.
About the preview returning public, max-age=300: I think that was correct. The preview runs as its own forge with its own accounts, so a token issued by forge.open-platform.sh matches no account there and the caller is treated as anonymous. The token test above shows that a token the serving forge does know reaches roleOf with the user set.
Checks run: bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. scripts/fast.ts named 1853 files, too many to run here, so I ran 40 files myself and all pass: every changed test file, all openapi* and contract-openapi* tests, mcp, api-page*, and the suites that request /api.
Try it: open /api signed out and look for any /api/v1/admin/… route; there should be none. Sign in as a site admin and every operation is back.
🤖 Generated with Claude Code
Read the visible diff (cut at 61 KB; I did not see the tail of the test edits). The cut works at the document, the /api page and the terminal screen. Role is a required argument, so nothing defaults to admin. Cache keys, ETag, cache-control and vary are per role. Tests cover anonymous, member and admin, and include a real-HTTP bearer-token test. One nit, not blocking: in test/contract-openapi-roles.test.ts the unknown-token check accepts either 401 or 200 and asserts only on 200, so it can pass without asserting anything. Pin the status the forge actually answers.
GET /contract/openapi.json(and/openapi.json,/api/v1/openapi.json) now lists only the routes the caller's role may call. A request with no credential gets thex-role: anonymousrows, a member's token or session adds thememberrows, and a site admin gets all three. Before this, anyone could read all ~410 rows, 80 of them admin-only.What changed
src/contract/openapi.ts:openapi()takes a role and keeps only the rows that role may call. The tag list only includes tags that still have rows, so an anonymous reader doesn't see tags that only admin rows carry.src/contract/served.ts:- The new
roleOf()works out the role from the request's signed-in user. - The cache is keyed by (version, origin, role) for the whole document and by (version, origin, role, tag) for?tag=slices. Each limit is three times what it was, one share per role. - The ETag hashes the role's own text, so it differs per role and an admin's tag never gets a 304 for an anonymous request. - Every response carriesvary: accept-encoding, authorization, cookie. Only the anonymous answer iscache-control: public; the other two areprivate.docs/MCP.md: the Contract entry says the document is cut to the caller's role.Tests
test/contract-openapi-roles.test.tsasks as anonymous, member and admin. It checks the row counts for each role, fails if any admin row or admin-only tag reaches the first two, and checks the per-role ETags, the 304, the cache headers and?tag=slices per role. All three of its tests fail against the old code.- In
test/contract-openapi-tags.test.ts, a request with no credential is now compared with the anonymous cut instead of the whole document. - 14 suites fetched the document anonymously to check that a member or admin route is described. They now ask as a site admin through a new helper,test/helpers/openapi-doc.ts. The mcp test that checks the/api/v1/openapi.jsonredirect is unchanged.Not done
/apipage (src/web/pages/api.ts) and the terminal's API screen still callopenapiTextwith its default role,admin, so/apistill shows every row to anyone. Cutting it per role needs its own per-role cache; that's a follow-up issue.Checks run
bun scripts/gate.ts,bun x tsc --noEmitandbun test test/mcp-size.test.tspass.bun scripts/fast.tsnamed 1,848 files, over its budget, so I picked the tests myself: every test file that mentions the OpenAPI document, 943 tests, all passing.bun scripts/design-lint.ts, since nothing undersrc/web/changed.To try it:
curl $ORIGIN/contract/openapi.json | jq '[.paths[][]."x-role"] | unique'should return["anonymous"]. The same request with an admin'sauthorization: Bearer …header returns all three roles.🤖 Generated with Claude Code
Closes #11286