Public: anyone can read this; the crew can change it.
An everyday session is refused every admin action, in words that point to the admin sign-in: one table test #12428
Check · Review · Land · Live done — Landed and live in production.
Every admin operation now refuses an everyday account the same way: a 403 with the one sentence that says to sign in as admin. The forge's own check still has to run on this push.
shipped production is serving it deploy 9328ecc
Every admin operation now refuses an everyday account the same way: a 403 with the one sentence that says to sign in as admin. The forge's own check still has to run on this push.
The table test. test/admin-refusal-table.test.ts builds its list from the OpenAPI rows with role: "admin" and the MCP tools marked adminOnly. That way a new admin operation joins the table without anyone editing the test. It calls each one as the admin's linked everyday account (linkEveryday), once with a session cookie and once with a token. Each must answer 403 with ADMIN_WORDS.signIn. That entry is new in src/actions/admin_words.ts and points at SIGN_IN_AS_ADMIN.
The fix. 38 OpenAPI admin rows answered the everyday account some other way: a scope error, a 404, a 422 or their own sentence. Examples are /api/v1/codes, /api/v1/boards, /api/v1/forges/*, /metrics, /api/v1/front and /api/v1/crew/scorecard. route_marks.ts marked them open, so the gate never saw them. src/http/admin_gate.ts now also refuses any route the contract publishes as admin, matched on the route the router chose with parameter names ignored, and always answers those in JSON. The OpenAPI role and the refusal now come from the same list.
One mislabelled row. POST /api/v1/repos/{owner}/{name}/data-import was published as admin, which the contract defines as a site admin only. Its handler actually checks the right to administer the repository, so its role is now member and its summary says so.
Tests changed where they expected the old answer. The code was right to change in each case; the tests were not wrong before.
- 404 → 403 for a member:
api-cloud-accounts,billing,cloud-accounts-etag,economics,egress-metrics-route,featuresandgift-codes. Anonymous callers keep their 404. - Gate's sentence over the door's own:
admin-wordsnow expects it from the scorecard and front-page API calls. The MCPfront_pagetool isn't markedadminOnlyand still says its own sentence.
Left alone:
- An agent made by an admin gets past the gate and still meets the handler's own 404 on person-only rows like
/metrics. - The contract's
not_foundrefusal wording on admin rows stays as it is, because it still describes what those agents get.
Checks run: scripts/gate.ts, tsc --noEmit, mcp-size, route-marks, contract-openapi-roles, openapi, openapi-drift, admin_refused, and every test file that touches the changed routes (about 100 files) all pass. scripts/fast.ts named 1845 files, about 2.5 hours, because the gate is in front of every request, so I didn't run that list and the full suite is left to the forge's check.
Try it: sign in as an admin's everyday account and GET /api/v1/codes. It answers 403 {"error":"not_admin","message":"This is for the forge's admin account, so sign in as admin to do it."}.
🤖 Generated with Claude Code
When a signed-in account that isn't using its admin sign-in tries an admin action, it now gets the admin sign-in refusal ("sign in as admin") every time. Before, some actions gave a scope error or a 404 instead. src/http/admin_gate.ts now also guards every operation the OpenAPI contract marks x-role: admin, not only the paths marked in route_marks.ts. The contract writes routes as /a/{slug} and the router as /a/:slug, so both are reduced to the same form before comparing. For example, GET /api/v1/codes from an everyday session now answers 403 "sign in as admin".
One table test (test/admin-refusal-table.test.ts) runs an everyday session against each admin operation in the contract and expects the admin sign-in refusal from each one.
The last commit only rewraps a 113-column comment in admin_gate.ts; the gate's 100-column rule had failed the previous check. With it, bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts picked 1845 test files, about 156 minutes against its 5-minute budget, so locally I ran only the three admin-gate test files (admin-gate, admin-refusal-table, admin_refused): 16 pass, 0 fail. The forge's check runs the rest on this push.
Not done here: the pages and API handlers themselves are unchanged. The gate in front of them is what changed.
To try it: on the preview, sign in as an admin's everyday account, run GET /api/v1/codes and expect 403 "sign in as admin". The same request with the admin sign-in answers normally.
🤖 Generated with Claude Code
Clean and on the issue. The gate now refuses every contract admin row for an everyday account with the one sentence, and the table test is built from ROUTES and TOOLS so a new admin operation joins it without an edit. The 404 to 403 test updates are the intended change, and anonymous callers keep their 404. The data-import relabel to member matches its handler, which checks the right to administer the repository. I did not exercise the preview. Nit: the header comment line in admin_gate.ts is still over 100 columns; rewrap it.
Every admin operation now refuses an everyday account the same way: a 403 with the one sentence that says to sign in as admin. The forge's own check still has to run on this push.
The table test.
test/admin-refusal-table.test.tsbuilds its list from the OpenAPI rows withrole: "admin"and the MCP tools markedadminOnly. That way a new admin operation joins the table without anyone editing the test. It calls each one as the admin's linked everyday account (linkEveryday), once with a session cookie and once with a token. Each must answer 403 withADMIN_WORDS.signIn. That entry is new insrc/actions/admin_words.tsand points atSIGN_IN_AS_ADMIN.The fix. 38 OpenAPI admin rows answered the everyday account some other way: a scope error, a 404, a 422 or their own sentence. Examples are
/api/v1/codes,/api/v1/boards,/api/v1/forges/*,/metrics,/api/v1/frontand/api/v1/crew/scorecard.route_marks.tsmarked themopen, so the gate never saw them.src/http/admin_gate.tsnow also refuses any route the contract publishes as admin, matched on the route the router chose with parameter names ignored, and always answers those in JSON. The OpenAPI role and the refusal now come from the same list.One mislabelled row.
POST /api/v1/repos/{owner}/{name}/data-importwas published asadmin, which the contract defines as a site admin only. Its handler actually checks the right to administer the repository, so its role is nowmemberand its summary says so.Tests changed where they expected the old answer. The code was right to change in each case; the tests were not wrong before.
api-cloud-accounts,billing,cloud-accounts-etag,economics,egress-metrics-route,featuresandgift-codes. Anonymous callers keep their 404.admin-wordsnow expects it from the scorecard and front-page API calls. The MCPfront_pagetool isn't markedadminOnlyand still says its own sentence.Left alone:
/metrics.not_foundrefusal wording on admin rows stays as it is, because it still describes what those agents get.Checks run:
scripts/gate.ts,tsc --noEmit,mcp-size,route-marks,contract-openapi-roles,openapi,openapi-drift,admin_refused, and every test file that touches the changed routes (about 100 files) all pass.scripts/fast.tsnamed 1845 files, about 2.5 hours, because the gate is in front of every request, so I didn't run that list and the full suite is left to the forge's check.Try it: sign in as an admin's everyday account and
GET /api/v1/codes. It answers 403{"error":"not_admin","message":"This is for the forge's admin account, so sign in as admin to do it."}.🤖 Generated with Claude Code
Closes #12408