Public: anyone can read this; the crew can change it.
Every admin-only action refuses the everyday account's session: one test walks the route table and names any that does not #11840
Check · Review · Land · Live done — Landed and live in production.
An everyday (non-admin) account that opens an admin-only page or API route, or calls an admin MCP tool by name, now gets a 403 with one sentence: "This is for the forge's admin account, so sign in as admin to do it." The admin passes through to the handler. Before, most of those pages answered an everyday account with a bare 404.
shipped production is serving it deploy 938e2e9
An everyday (non-admin) account that opens an admin-only page or API route, or calls an admin MCP tool by name, now gets a 403 with one sentence: "This is for the forge's admin account, so sign in as admin to do it." The admin passes through to the handler. Before, most of those pages answered an everyday account with a bare 404.
The admin mark, as data in one module. New src/http/route_marks.ts marks every route both routers register as admin or open:
- Most routes are marked by their head: the first path segment, or
/api/v1/<segment>on the API. - Heads that hold both kinds (
/settings,/forges,/packs,/live,/boards) aremixed, and each route under them is listed one by one. A route listed there overrides its head; that is how/admin/flowskeeps its own not-found (#9594). admin_gate.ts'sisAdminPathnow reads these marks instead of three fixed prefixes. The gate covers the admin pages that used to refuse with 404 inside their handlers:/settings/billing|cloud|backups|accounts|people|crew|…,/forges,/system,/maintenance,/pinned,/deploy-to,/resilience,/boards/data,/live/home/kpis|panels, and/packs/new|mirror|….- The sentence is
SIGN_IN_AS_ADMINincore/roles.ts. The JSON API still answerserror: "not_admin".
MCP. A tool marked adminOnly stays out of an everyday account's tool list. Calling it by name returns the same JSON-RPC error code as before, no tool named X for this account., followed by the sentence (notAdminTool in mcp/shared.ts).
test/route-marks.test.ts (would fail on main):
- It walks both routers and fails on any route marked neither, and on any mark that names a route or head that no longer exists.
- It calls every route marked admin as a member and as the admin. The member must get the 403 with the sentence; the admin must not be refused and must not get a 5xx. A GET with no path parameters must answer the admin outright, apart from three named pages that need data a bare forge lacks.
- It calls every
adminOnlytool as the member, expecting the sentence, and checks each tool is listed to the admin.
No admin route was found open to an everyday account. All of them refused it, mostly with a 404, so nothing needed a separate failing-first fix.
What to look at:
- Marks built from a probe. I classified the routes by calling every one as a member and as the admin and comparing the answers, then reading the handlers where the probe couldn't tell.
/boards(all but/boards/data),/data,/flowand/settings/usageare markedopenbecause members use them or they refuse in their own way./settings/usagestill refuses with its own 403, not the new sentence. - About 60 test files changed. Their assertions expected a 404 for a non-admin on these pages; that was the old behaviour, not a code bug, so they now expect the gate's 403. In
system-timingI dropped the expectation of areadertiming phase, because the gate answers before the handler runs.claim-revokeandtraffic-sourcesnow expect 403 for the member and still 404 for a stranger or for an app's own page. - Admin side of the MCP tools. It is checked by listing the tools, not by calling them, so the test starts no backups, mirrors or snapshots.
- New routes. A new route under a fully
adminoropenhead inherits that mark. Only routes under amixedhead, or under a brand-new head, have to be marked by hand. - Not done: I did not record a plan on the issue with
keep_plan; that tool isn't available in this sandbox.
Checks run: bun scripts/gate.ts, bun x tsc --noEmit, test/mcp-size.test.ts, and the 116 test files that touch the gated paths, plus the admin, MCP and origin suites. Two failures are left in that set:
guest-contributefails the same way on main, because the sandbox blocks the git push it makes.- Two
/boards/datacases inboards-guidefailed in the big run but pass when the file runs alone, here and on main.
Try: sign in to the preview as a non-admin account and open /settings/billing or /system. A 403 page says to sign in as admin; as the admin, both open.
🤖 Generated with Claude Code
Every admin-only route and MCP tool now refuses an everyday account with a 403 and one sentence, which comes from SIGN_IN_AS_ADMIN in src/core/roles.ts: "This is for the forge's admin account, so sign in as admin to do it." The API sends it as JSON (not_admin) and a browser gets a page. An admin sees no change.
How it works. src/http/route_marks.ts marks every registered route admin or open, as data. It does this mostly by the route's first path segment, and lists routes one by one where a segment holds both kinds. admin_gate.ts refuses every path marked admin before any handler runs. Before this change the gate only covered /admin/, /api/v1/admin/ and /releases. Now it also covers the admin's pages under /settings/*, /capacity, /resilience and the others marked admin. An everyday account that calls an admin-only MCP tool by name is told the tool is not there for this account, plus the same sentence. /admin/flows keeps its own not-found answer (#9594).
The test. test/route-marks.test.ts walks both routers and the MCP tools. It fails on any route marked neither admin nor open, so a new route has to choose one. For each route marked admin, it checks that an everyday session gets the 403 and an admin session gets through.
Fixes after the first check failed:
- Main had added
POST /settings/accounts/:username/resetand/end-sessions. Both are now markedadmin. - The variable in
route_marks.tsthat matches marked routes is renamed fromroutertomatcher. The OpenAPI coverage scanner reads everyrouter.add(call as a registered route and failed on it. - About thirty assertions in 23 test files expected the old 404 for an everyday account on routes that are now gated. They now expect the 403. These tests were wrong for the new behaviour; the code is unchanged. Some test titles still say "404" or "not found".
bun scripts/gate.ts passes, bun x tsc --noEmit is clean, and the 535 tests in the affected files pass. The one check failure outside this change, "a new head on a pull stops its running check…" in test/checks-queue.test.ts, passes here with the rest of its file.
To try it: on the preview, sign in as a member who is not an admin and open /settings/billing, or call capacity over MCP. Either should answer with the one sentence. An admin sees the page or tool as before.
🤖 Generated with Claude Code
The admin gate now covers POST /settings/accounts/:username/way-back, a route main added after this branch's last merge. With that route marked, the route-marks test passes again, since it requires every registered route to be marked admin or open.
Some tests had expected the old 404 when an everyday account reached an admin page. I wasn't sure whether that was a test bug or a code bug, and decided the tests were wrong: the gate's refusal for these routes is a 403, which is what this pull is for. Three asserts now expect 403:
test/account-access.test.ts: GET/settings/accounts/boband POST/settings/accounts/bob/resetwith bob's cookie.test/edge-gone.test.ts: POST/settings/accounts/ada/way-backwith bea's cookie.
The branch is merged with the current main.
The other two failures in the last check log, "a real request held open across a stall…" (test/stall-in-flight.test.ts) and "the abandoned-checks timer sweeps…" (test/checks-home-sweep.test.ts), pass here both before and after the merge. They look timing-sensitive under load rather than caused by this change, so I left them alone.
Checked before pushing: bun scripts/gate.ts and bun x tsc --noEmit pass, and so do the route-marks, account-access, edge-gone, tui-way-back, palette-roster, stall-in-flight and checks-home-sweep tests.
To try it: sign in as a non-admin and open /settings/accounts/<someone> on the preview. It answers 403. Signed in as an admin, the same page answers 200.
🤖 Generated with Claude Code
Direction is right and the gate refuses well, but four things need fixing in one round.
- MCP ordering (first fix): in src/http/mcp.ts the adminOnly refusal runs after askedFirst/heldWrite. An everyday account calling an admin-only write tool by name can reach those first, which may record a held write or a confirm ask for a tool they may never run. Put the adminOnly refusal before askedFirst/heldWrite, and add a test that an everyday call leaves no held write or ask behind.
- Box 3 is not met as written. Routes under an
openoradminhead inherit its mark without choosing, so a new route under/api/v1/<head>(all markedopen) or a new/settings/...path is not forced to choose. Unlisted/settings/*paths fall through to open in isAdminRoute (fail-open). Make the test require every registered pattern to be listed explicitly (or require a per-route mark for any head holding admin routes), and make isAdminRoute default unlisted routes under amixedhead to admin, not open.
- Box 1 is partly met: admin MCP tools are only checked by listing for the admin, never called. Call at least the read-only admin tools as the admin and assert they work, and keep the write ones listed-only.
- Hygiene: about 30 tests still have titles saying 404 / not found while asserting 403; rename them. The docs sentence in ROLES.md is fine.
/settings/usageand/settings/resiliencestill refuse with their own wording, so the one-sentence refusal is not uniform; say so in docs or move them.
Note: the 403 now confirms the existence of admin routes and tool names to everyday accounts; acceptable per the issue, but worth one line in the pull.
Every admin-only route and MCP tool now refuses an everyday account with a 403 and one sentence: "This is for the forge's admin account, so sign in as admin to do it." This round covers all four points from review; the push is up.
1. MCP order. src/http/mcp.ts now refuses an admin-only tool to a non-admin before askedFirst/heldWrite run. The adminOnly guards inside those two functions in src/http/mcp/shared.ts were redundant, so they are gone, and this early refusal is the only rule. The new test has an everyday account's conversation token read fenced text first, then call every admin tool by name. It checks that each call is refused with the sentence and that nothing is held for that person. With the old order and those guards removed, this test fails.
2. A new route must choose, and a missing mark fails closed.
/adminwas anadminhead with one open exception (/admin/flows), so it is nowmixedand each of its routes is listed.- A new test fails if a head that isn't
mixedholds an exception. Every head with both kinds of route therefore lists each one. isAdminRoutenow treats a path under amixedhead that matches no listed route asadmin, not open.- The gate now uses the route the real router matched and only falls back to the path when there is none. Without this, the git path
/packs/demo.git/info/refs(a user namedpacks) counted as a/packsadmin page, and a test caught it. A test now covers it. - A new route under a head marked wholly
openor whollyadminstill inherits that mark without being listed. I kept this, which is the "or" option you offered: those heads hold only one kind of route, and the admin ones fail closed anyway.
3. Admin tools are called as the admin. Each read-only admin tool (writes: false) is called as the admin and must answer without error. The write tools are still only checked as listed.
4. Cleanup.
- 28 test titles now say 403 where they check for it. Two titles mention both codes because their tests check both.
/settings/usageand/settings/resilienceare now markedadmin, so they refuse with the same sentence as every other admin route. Intest/usage-timing.test.tsI changed the test, not the code: the gate now refuses before the page runs, so it no longer expects the page'ssessiontiming phase. It still checks that no later phase ran.docs/ROLES.mdexplains how mixed heads fail closed. It also says that the 403 tells an everyday account that an admin route or tool with that name exists.
Before pushing, bun scripts/gate.ts and bun x tsc --noEmit passed, and so did every touched test file plus the conversation, MCP, packs and admin-gate tests. In one big run of all 132 test files changed from main, two /boards/data tests in test/boards-guide.test.ts failed. That file passes on its own (three times), alongside test/route-marks.test.ts, and with my changes stashed. It looks like state shared between files in one process, not this change; the forge's sharded check will show whether it holds.
To try it, open /settings/usage, /admin/limits or /boards/data on the preview as a non-admin: each answers 403 with the sentence. As an admin, the same pages answer normally.
🤖 Generated with Claude Code
Approved. Earlier asks, read against the diff: (1) MCP order is met: the admin-only refusal now runs in mcp.ts before askedFirst/heldWrite, and the redundant guards in shared.ts are gone. (2) Mixed heads fail closed is met: /admin is mixed with every route listed, isAdminRoute treats an unlisted path under a mixed head as admin, and the gate uses the router-matched pattern. (3) Read-only admin tools are called as admin: met per the description. (4) Cleanup is met: /settings/usage and /settings/resilience are marked admin, the docs/ROLES.md note is added, and the titles were updated. Caveat: my clone does not contain the b36c947a236f head, so I judged the full diff as given and could not diff the round. Non-blocking: the 403 confirms an admin route exists by name, which docs/ROLES.md states openly.
An everyday (non-admin) account that opens an admin-only page or API route, or calls an admin MCP tool by name, now gets a 403 with one sentence: "This is for the forge's admin account, so sign in as admin to do it." The admin passes through to the handler. Before, most of those pages answered an everyday account with a bare 404.
The admin mark, as data in one module. New
src/http/route_marks.tsmarks every route both routers register asadminoropen:/api/v1/<segment>on the API./settings,/forges,/packs,/live,/boards) aremixed, and each route under them is listed one by one. A route listed there overrides its head; that is how/admin/flowskeeps its own not-found (#9594).admin_gate.ts'sisAdminPathnow reads these marks instead of three fixed prefixes. The gate covers the admin pages that used to refuse with 404 inside their handlers:/settings/billing|cloud|backups|accounts|people|crew|…,/forges,/system,/maintenance,/pinned,/deploy-to,/resilience,/boards/data,/live/home/kpis|panels, and/packs/new|mirror|….SIGN_IN_AS_ADMINincore/roles.ts. The JSON API still answerserror: "not_admin".MCP. A tool marked
adminOnlystays out of an everyday account's tool list. Calling it by name returns the same JSON-RPC error code as before,no tool named X for this account., followed by the sentence (notAdminToolinmcp/shared.ts).test/route-marks.test.ts(would fail on main):adminOnlytool as the member, expecting the sentence, and checks each tool is listed to the admin.No admin route was found open to an everyday account. All of them refused it, mostly with a 404, so nothing needed a separate failing-first fix.
What to look at:
/boards(all but/boards/data),/data,/flowand/settings/usageare markedopenbecause members use them or they refuse in their own way./settings/usagestill refuses with its own 403, not the new sentence.system-timingI dropped the expectation of areadertiming phase, because the gate answers before the handler runs.claim-revokeandtraffic-sourcesnow expect 403 for the member and still 404 for a stranger or for an app's own page.adminoropenhead inherits that mark. Only routes under amixedhead, or under a brand-new head, have to be marked by hand.keep_plan; that tool isn't available in this sandbox.Checks run:
bun scripts/gate.ts,bun x tsc --noEmit,test/mcp-size.test.ts, and the 116 test files that touch the gated paths, plus the admin, MCP and origin suites. Two failures are left in that set:guest-contributefails the same way on main, because the sandbox blocks the git push it makes./boards/datacases inboards-guidefailed in the big run but pass when the file runs alone, here and on main.Try: sign in to the preview as a non-admin account and open
/settings/billingor/system. A 403 page says to sign in as admin; as the admin, both open.🤖 Generated with Claude Code
Closes #11818