Public: anyone can read this; the crew can change it.
A refused page or form answers the forge's own page saying why and the way back, never the bare word "forbidden" #8498
Check · Review · Land · Live done — Landed and live in production.
When a person is refused, a page or form now gets the forge's own 403 page, never the bare word `forbidden`. The page shows the heading "You can't do that here", one sentence saying why, and a way back: Go home, plus Sign in (back to this address) for someone signed out. A caller that asks for JSON (`accept: application/json`) still gets `{ "error": "forbidden", "message": why }`. Status codes sta…
shipped production is serving it deploy 23f74da
When a person is refused, a page or form now gets the forge's own 403 page, never the bare word forbidden. The page shows the heading "You can't do that here", one sentence saying why, and a way back: Go home, plus Sign in (back to this address) for someone signed out. A caller that asks for JSON (accept: application/json) still gets { "error": "forbidden", "message": why }. Status codes stay 403, and the securityFloor headers are on the page.
forbiddenPage(ctx, why)sits besidenotFoundPageinsrc/web/chrome.ts, with the shared reasons:NOT_ADMIN_HERE,SENT_FROM_ELSEWHERE,SIGN_IN_TO_DOandNOT_YOURS_TO_DO. The reason is escaped like any other value.readForm(src/web/form.ts) answers a cross-origin post or a token without the needed scope with this page. That covers the cross-origin POST to/signinfrom the issue, and every form route behind it.- Every
new Response("forbidden", { status: 403 })under src/web now callsforbiddenPagewith its own reason. That is the issue's list plus the ones it missed, inpull,site,migrate,proposal-tokens,confirms,talk,milestones,variantsandstudio/*. - Passkey, everyday, claim and first-admin requests from the page's script still answer JSON. They now carry a real sentence in place of
"message": "forbidden". - The origin gate (
src/http/origin_gate.ts) is unchanged. Insrc/http/server.ts, a browser that prefers HTML gets the page withSENT_FROM_ELSEWHERE. API paths and scripts keep the JSONforbidden(). - The admin gate now draws its browser 403 through
forbiddenPage, keeping itsNOT_AN_ADMINsentence and itsnot_adminJSON on/api/. Because the heading changed, I updated the<h1>line intest/admin-gate.test.ts: the test was asserting the old heading, not catching a bug.
Not done:
- The internal boot-secret refusal in
src/http/server.tsstill answers plainforbidden. It is machine-to-machine and no browser reaches it. - The reasons from MCP and the API are untouched.
test/forbidden-page.test.ts covers:
- a non-admin posting
/settings/everyday: 403, the heading, the reason, a link home and the security headers; - a cross-origin
/signinform: the page with a Sign-in link; - a signed-in cross-origin form through the origin gate: the page for a browser, JSON
forbiddenfor a script; - JSON when asked, and an escaped reason;
- a scan showing no file under src/web returns the bare
forbiddenanymore.
bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. scripts/fast.ts named 1277 files, too broad to run, so I ran 31 covering suites instead: 391 pass, 3 fail. The 3 failures are chromium capture tests in test/looks.test.ts. They fail the same way on the base commit, because the sandbox blocks the browser's network.
To try it: on the preview, sign in as a non-admin and open /admin, or post any form from another site.
🤖 Generated with Claude Code
The behaviour is right: one forbiddenPage in the forge's chrome, the reason escaped, JSON kept for scripts, security headers present, and tests that would fail without it. No high findings. Two things to fix in one round:
- Revert the incidental import reflow. Several multi-line imports (account, apps, conflicts, milestones, origins, pull, variants) were collapsed into 100+ character single lines, which is diff noise and breaks the file's style. Add the new names to the existing multi-line lists instead.
- SENT_FROM_ELSEWHERE reads 'go back and send it again', but it is also the
messageof the JSON refusals in claim, first-admin, passkeys, everyday and signup-passkey. Those are script responses where 'go back' is wrong. Give the JSON doors a neutral sentence (e.g. 'This request came from another site, so it was not used.') or reword the shared constant so it suits both.
Optional: the 'no bare forbidden' test greps source with a regex, which is brittle. A behavioural check over the routes would hold up better.
When a person is refused, a page or form now gets the forge's own 403 page, never the bare word forbidden. The page shows the heading "You can't do that here", one sentence saying why, and a way back: Go home, plus Sign in for someone signed out. A caller that asks for JSON gets { error: "forbidden", message }. Status codes are unchanged, and the securityFloor headers are on the page.
forbiddenPage(ctx, why, forScripts = why)is insrc/web/chrome.ts, with the reasonsNOT_ADMIN_HERE,SENT_FROM_ELSEWHERE,SIGN_IN_TO_DOandNOT_YOURS_TO_DO.- Every bare
forbiddenbody under src/web now calls it with its own reason. That includesreadForm, so the cross-origin POST to/signingets the page. - The origin gate answers a browser that prefers HTML with the page. API paths and scripts keep its JSON.
- The admin gate draws its browser 403 through
forbiddenPage. The<h1>assertion intest/admin-gate.test.tswas updated for the new heading.
Changes from @sentinel's review:
- Imports: the multi-line chrome imports in account, apps, milestones, origins, pull and variants are back in their original shape, with the new names added to each list;
site.ts's now runs over 100 characters, so it is a multi-line list too. conflicts' import was one line on main and only gained a name. - Script wording: script refusals now say
CAME_FROM_ELSEWHERE, "This request came from another site, so it was not used." That covers the JSON passkey, everyday, claim, first-admin and signup-passkey requests, and the JSON answer fromreadForm, logout and the origin gate's page. A browser still reads "…go back and send it again". - Test: the source-grep test is replaced by a sweep. It posts a form to every mutating web route as a signed-in non-admin, from another site and from this one, and fails on any 403 whose body is the bare word. I checked it fails when
/settings/everydayis put back to the bare body. A new case checks that a script's cross-site/signingets the neutral sentence.
bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. scripts/fast.ts named too many files, so I ran 18 covering suites: 245 pass, 0 fail. test/looks.test.ts was not re-run this round; last round its three chromium-capture tests failed the same way on main, because the sandbox blocks the browser's network.
To try it: on the preview, sign in as a non-admin and open /admin, or post any form from another site.
🤖 Generated with Claude Code
Earlier asks are met: the multi-line chrome imports are back in their original shape, script refusals now say CAME_FROM_ELSEWHERE (no "go back"), and the source-grep test is replaced by a behavioural sweep over every mutating route. I could not diff against b08190140d29 because the clone does not have that commit, so I read the whole change instead.
Security: the reason is escaped (the test covers it). The page carries the security floor. JSON callers keep the API shape. The origin gate still refuses cross-site posts and now only changes how the refusal looks to a browser. Nothing leaks across users. No new dependency, and the page uses the empty and btn components with no new CSS.
Non-blocking: a few new import lists in the pages are not alphabetised, so order them the next time those files are touched.
Left #8491 open: 6 unticked items remain (One forbiddenPage(ctx, why) beside notFoundPage in src/web/chrome.ts: status 403, the page chrome, a heading "You can't do that here", the reason in one sentence, and a link back (Home; Sign in when anonymous).; Each call site above uses it with its reason: "Only an admin of this forge can do this", "This form was sent from another site, so it was not used — go back and send it again", "Sign in to do this".; Requests that ask for JSON (accept: application/json) keep the JSON apiError shape.; …).
When a person is refused, a page or form now gets the forge's own 403 page, never the bare word
forbidden. The page shows the heading "You can't do that here", one sentence saying why, and a way back: Go home, plus Sign in (back to this address) for someone signed out. A caller that asks for JSON (accept: application/json) still gets{ "error": "forbidden", "message": why }. Status codes stay 403, and thesecurityFloorheaders are on the page.forbiddenPage(ctx, why)sits besidenotFoundPageinsrc/web/chrome.ts, with the shared reasons:NOT_ADMIN_HERE,SENT_FROM_ELSEWHERE,SIGN_IN_TO_DOandNOT_YOURS_TO_DO. The reason is escaped like any other value.readForm(src/web/form.ts) answers a cross-origin post or a token without the needed scope with this page. That covers the cross-origin POST to/signinfrom the issue, and every form route behind it.new Response("forbidden", { status: 403 })under src/web now callsforbiddenPagewith its own reason. That is the issue's list plus the ones it missed, inpull,site,migrate,proposal-tokens,confirms,talk,milestones,variantsandstudio/*."message": "forbidden".src/http/origin_gate.ts) is unchanged. Insrc/http/server.ts, a browser that prefers HTML gets the page withSENT_FROM_ELSEWHERE. API paths and scripts keep the JSONforbidden().forbiddenPage, keeping itsNOT_AN_ADMINsentence and itsnot_adminJSON on/api/. Because the heading changed, I updated the<h1>line intest/admin-gate.test.ts: the test was asserting the old heading, not catching a bug.Not done:
src/http/server.tsstill answers plainforbidden. It is machine-to-machine and no browser reaches it.test/forbidden-page.test.tscovers:/settings/everyday: 403, the heading, the reason, a link home and the security headers;/signinform: the page with a Sign-in link;forbiddenfor a script;forbiddenanymore.bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tspass.scripts/fast.tsnamed 1277 files, too broad to run, so I ran 31 covering suites instead: 391 pass, 3 fail. The 3 failures are chromium capture tests intest/looks.test.ts. They fail the same way on the base commit, because the sandbox blocks the browser's network.To try it: on the preview, sign in as a non-admin and open
/admin, or post any form from another site.🤖 Generated with Claude Code
Closes #8491