Public: anyone can read this; the crew can change it.
Roles, part 3a: a member is refused admin settings and releases, with a 403 on the API and a plain line on the page #2403
Check · Review · Land · Live done — Landed and live in production.
A member is now refused admin settings and releases with a 403 and one plain sentence; admins see no change. The branch is merged with current `main` and pushed. I did not touch the code this round.
shipped production is serving it deploy e11532c
A member is now refused admin settings and releases with a 403 and one plain sentence; admins see no change. The branch is merged with current main and pushed. I did not touch the code this round.
The earlier failure: the check failed on review-wait.test.ts ("a green agent pull and its reviewer > before the review it is the crew's…"). That test never touches admin or release routes. It passed three runs in a row here, alone and alongside the new test, so I read it as a flake, not something this change caused. I don't know why it failed in the forge, and it may fail again.
Type check: tsc --noEmit stopped at "cannot find type definition file for 'bun'". The sandbox denied the bun install that would fetch it, so the type check was not completed here, though it was clean on the first round.
What changed
src/http/admin_gate.tsis one gate ahead of every handler, covering/admin/,/api/v1/admin/, and the/releasespage with its rollback.- A signed-in member or guest gets 403 with the sentence in the new
NOT_AN_ADMINconstant: "only an admin can change the forge's settings or its releases". The API returns{error: "not_admin", message}; a browser gets a 403 page with the same line. - Admins, signed-out callers and the public
/releases.jsonbehave as before. - The gate follows the
guard_guestsswitch. test/admin_refused.test.tsis new. It checks that a member is refused nine admin API acts, the admin pages and POSTs, and/releaseswith its rollback. It also checks that an admin gets through on the reads I sampled.docs/ROLES.mdhas a short part-3a note.
Left out: token scopes (a later part), the pull "hold/release" route (a different thing), and the /settings/* crew, cloud, backups and secrets pages, which keep their existing 404 for non-admins.
Try it: bun test test/admin_refused.test.ts. Or, with guard_guests on, sign in as a member and open /admin/limits or /releases.
🤖 Generated with Claude Code
High: the terminal is not gated, so a member is refused releases on the page but can still read them and roll back over SSH.
src/release/gate.ts is where the rule lives for both doors. Its header says the /releases page and the SSH terminal run the same refusals, and that "neither client re-states a rule". This pull adds the administer refusal in src/http/admin_gate.ts instead, which only HTTP passes through. src/tui/screens/releases.ts and the /releases entry in src/tui/forge.ts:330 still ask only mayRead/mayRollBack. Those check "site admin or reader/writer of FORGE_REPO". So with guard_guests on, a member who can write the forge's own repository:
- gets a 403 on
GET /releasesandPOST /releases/:sha/rollback; - can still open Releases in the terminal and press Enter to roll production back.
That breaks "the terminal is a door like the page", on the most dangerous act this pull covers.
Fix this first: put the refusal in the shared rule. In release/gate.ts, have mayRead and mayRollBack return false when lacks(app.db, user, "administer"). Then the page, the terminal and any later client all refuse the same way. Keep the HTTP gate for the 403 wording if you like. Then add a test that would fail today:
- make a member a writer of a FORGE_REPO, with
guard_guestson; - assert
mayRollBack/mayReadare false for them, and true for an admin; - assert the TUI releases screen shows no rows and no rollback for that member, in the same style as the existing tui tests.
Note the duplication too: once the rule is in release/gate.ts, the /releases prefix in isAdminPath restates it. Either drop it, or say in the module comment that it only supplies the wording.
Smaller points:
- The tests never give carol access to FORGE_REPO. So the
/releasesrefusal they assert is onemayReadalready gave (she would have seen a 404 anyway). Nothing tests the case this pull actually changes: a repository writer who is not an admin. - The type check was not run in the last round (the pull says so). Please run
bun x tsc --noEmitbefore the next push.
I could not reach the preview from my sandbox, so this verdict comes from the code.
A member is now refused releases on the page and in the terminal, with a 403 and one plain sentence on the API and the page. Admins are unchanged. I ran bun x tsc --noEmit (clean) and the admin, TUI-releases and releases-page tests (18 pass). The release-swap and release-checkout files ran earlier and passed, but I did not re-run them after the last edit. The forge's full check runs on this push.
What changed
- Shared rule:
src/release/gate.tsis where the releases rule now lives.mayReadandmayRollBackreturn false for anyone whose role lacksadminister, whatever they may do to FORGE_REPO. The/releasespage, the rollback POST, the terminal's palette entry and Releases screen, and any later client all refuse the same way. - Wording-only HTTP gate:
src/http/admin_gate.tsstays for/admin/and/api/v1/admin/, which it alone gates. For/releasesit now only supplies the 403 and its wording, and its header comment says so. - TUI test:
test/tui-releases.test.tshas a new case. A member who owns FORGE_REPO can roll back withguard_guestsoff, which proves the setup. With it on,mayReadandmayRollBackare false, the palette doesn't offer Releases, the screen shows no rows, and Enter offers no "Swap to". An admin still gets all of it. - HTTP test:
test/admin_refused.test.tsnow gives the member FORGE_REPO ownership too. It assertsGET /releasesand the rollback POST return 403 for them.
Left out: token scopes, a later part. The /settings/* crew, cloud, backups and secrets pages keep their existing 404 for non-admins.
Try it: bun test test/admin_refused.test.ts test/tui-releases.test.ts.
🤖 Generated with Claude Code
main is merged into the branch, the docs/ROLES.md conflict is resolved and the result is pushed. The full bun test run still had four failures, and I think none are from this change.
The conflict: main removed the guard_guests switch, so the refusal is always on. I kept main's paragraph on that and my part-3a paragraph, and dropped the old "off leaves the usual permissions" text.
Where the refusal now shows up: any signed-in person who is not an admin now meets a 403 on /admin/, /api/v1/admin/ and /releases. A signed-out caller or an agent still gets the old 404.
Tests I updated: several older tests expected a 404 for a non-admin on those doors and now expect 403. Those are capacity, signup, accounts, model-grant-pages, limits, quotas, invites, roles and releases-page.
What the earlier tests lost: the releases-page and tui-releases cases for "a reader of the forge's repository sees the releases" are rewritten, because a non-admin reader is now refused too. test/admin_refused.test.ts and test/tui-releases.test.ts no longer touch guard_guests.
What the full run showed:
- A scaffolded-app typecheck.
- The
main.tstest about a taken SSH port. - The chromium capture test.
- One
capacity.test.tscase that leavesFORGE_CHECK_SHARDSset. It fails the same way on a cleanorigin/mainworktree, so it predates this branch.
I did not rerun the other three on main.
bun x tsc --noEmit printed nothing, so it is clean. The forge's own check will also run on this push.
🤖 Generated with Claude Code
Approved. The change does what #2384 asks: one gate (src/http/admin_gate.ts) runs ahead of every handler for /admin/, /api/v1/admin/ and /releases. It refuses a signed-in person whose role lacks administer with a 403: not_admin plus the sentence in JSON, and an escaped page with the same line. Identity comes from the verified auth, so the gate never reads anything the client typed. Agents and signed-out callers still get the 404 they got before, and /releases.json stays public (under() does not match it).
The release rule lives in release/gate.ts (mayRead/mayRollBack), so the page and the terminal refuse the same way. The new TUI test proves this for a member who even owns FORGE_REPO. The terminal's admin screens (quotas, invites and the rest in tui/forge.ts) were already admin-only, so both doors agree.
Tests: test/admin_refused.test.ts is gated automatically, because scripts/gate.ts runs every file it does not exclude. Each refusal it checks would fail without the gate. The 404→403 updates in the older suites are the intended behaviour change, not weakened tests. I ran admin_refused, tui-releases, releases-page, limits, signup and roles at 336a10b4: 49 pass, 0 fail.
Two small notes, neither blocking:
- On the admin side, the success check only covers three GET reads. The other suites cover admin writes.
- The refusal runs before the rate limiter, the same as
proposalGatedoes. That is cheap, but a refused member's requests are not counted.
I could not reach the preview or the screenshots from my sandbox, so I judged the page from the code and the tests that render it.
A member is now refused admin settings and releases with a 403 and one plain sentence; admins see no change. The branch is merged with current
mainand pushed. I did not touch the code this round.The earlier failure: the check failed on
review-wait.test.ts("a green agent pull and its reviewer > before the review it is the crew's…"). That test never touches admin or release routes. It passed three runs in a row here, alone and alongside the new test, so I read it as a flake, not something this change caused. I don't know why it failed in the forge, and it may fail again.Type check:
tsc --noEmitstopped at "cannot find type definition file for 'bun'". The sandbox denied thebun installthat would fetch it, so the type check was not completed here, though it was clean on the first round.What changed
src/http/admin_gate.tsis one gate ahead of every handler, covering/admin/,/api/v1/admin/, and the/releasespage with its rollback.NOT_AN_ADMINconstant: "only an admin can change the forge's settings or its releases". The API returns{error: "not_admin", message}; a browser gets a 403 page with the same line./releases.jsonbehave as before.guard_guestsswitch.test/admin_refused.test.tsis new. It checks that a member is refused nine admin API acts, the admin pages and POSTs, and/releaseswith its rollback. It also checks that an admin gets through on the reads I sampled.docs/ROLES.mdhas a short part-3a note.Left out: token scopes (a later part), the pull "hold/release" route (a different thing), and the
/settings/*crew, cloud, backups and secrets pages, which keep their existing 404 for non-admins.Try it:
bun test test/admin_refused.test.ts. Or, withguard_guestson, sign in as a member and open/admin/limitsor/releases.🤖 Generated with Claude Code
Closes #2384