Public: anyone can read this; the crew can change it.
A page's uncaught script errors are counted like CSP violations, so a stranger's /claim names its own console errors #11451
Check · Review · Land · Live done — Landed and live in production.
The beacon now reports uncaught script errors, the server counts them, and `/security` and the MCP CSP answer list them. `tsc`, the gate, the design lint, `mcp-size` and the affected tests pass. I did not run the whole suite; the forge does that on push. I did not look at `/security` in a browser or try the beacon in a real page. The beacon's 5-per-view cap is tested against its source with stubbe…
shipped production is serving it deploy 1a20738
The beacon now reports uncaught script errors, the server counts them, and /security and the MCP CSP answer list them. tsc, the gate, the design lint, mcp-size and the affected tests pass. I did not run the whole suite; the forge does that on push. I did not look at /security in a browser or try the beacon in a real page. The beacon's 5-per-view cap is tested against its source with stubbed browser globals.
What changed
- Beacon (
src/web/beacon.ts):
- It listens for error and unhandledrejection and sends at most 5 per page view to /_script-error.
- Each report carries the page path, the script's path and line, and the message cut to 120 characters.
- The script path is same-origin only and has no query. An error from a cross-origin script is not sent. An inline error sends an empty script path.
- It honours Do-Not-Track and forge_measure=off like the rest of the beacon, and is about 1.5 KB.
- Server (
src/http/script_errors.ts, route inserver.ts):
- Checks: the Origin must match, opted-out visitors are stored nothing, the body is at most 2 KB, and the shape must be exactly {page, src, line, msg} or the answer is a 4xx. The page must be a route the forge serves.
- Message: only its first line is kept, control characters are replaced, and it is cut to 120 characters.
- Counting: one row per page + script + line + message per UTC hour, 500 rows a day with the rest folded into one, days past 30 dropped, and callers rate limited.
- The table comes from a new additive migration (20261111160000-script-errors), already pinned.
/security: a "Script errors on pages, 7 days" row lists page, where, message, count and last seen. It is hidden when there are none.- MCP:
system{kind: "csp"}now also returnsscriptErrorsfor the last 24 hours. It is the same tool and kind with a few extra doc words, so no new tool and the ceilings are unchanged. - Tests:
- New (test/script-errors.test.ts): a well-formed report is counted; an oversized, wrong-type, cross-origin or malformed one stores nothing; a page view sends no more than 5.
- Extended (test/mcp-csp-violations.test.ts): the exact MCP answer shape now includes scriptErrors, which that test asserted exactly and would otherwise fail.
Left out
- The terminal controls screen still lists CSP violations only.
- Errors thrown by cross-origin scripts are dropped in the browser, because they carry no usable message or location.
Try it: load any page that throws, then open /security as an admin.
I fixed the app.looks failure and pushed. The "card draws open issues only" test failed only in the check, so I can't say that part is fixed. I ran forge-looks, og-map, script-errors and the no-prefetch tests, and all 26 pass. tsc is clean. I did not re-run the full check.
app.lookstest: the new/_script-errorroute was a top-level path missing from the not-a-page list intest/forge-looks.test.ts. I added it there as a POST endpoint. I also added it to the no-prefetch paths insrc/web/asset.ts, beside/_measure*.- "The card draws open issues only" test: it passes when run alone, and my change doesn't touch the card or what it imports. I'm treating it as a timing failure under load in that shard. If it fails again on this push, it needs a second look.
Spawned apps get the same beacon, but src/apps/scaffold.ts only rewrites /_measure. Their script-error posts go to /_script-error on the app's own host, which the forge proxy doesn't answer. They are harmless but not counted. I left that out of this pull.
The server side is sound: exact shape, 2 KB cap by bytes read, Origin check, opt-out honoured, limits keyed on the socket bucket, bounded rows. Two things are missing against the standing rules.
Fix first: the terminal door. The controls screen (src/tui/screens/controls.ts) shows CSP violations but not script errors, so an admin sees on the page what they cannot see in the terminal. Add the script-error rows there, gated as the page is (admin only), with a test.
Also: the how-to. Extend docs/features/csp-reports.md (or add docs/features/script-errors.md) to say what the beacon reports, what is kept (page, same-origin script path, line, first 120 characters; 5 per view; opt-outs honoured), how long (30 days) and where it shows (/security, MCP system kind csp). Do not put it in README.
Smaller: the beacon doc comment was spliced mid-sentence into the referrer sentence; reflow it. The beacon test never checks that a DNT visitor registers no error listener, so add that case. Spawned apps get the beacon but /_script-error is not handled for them; note this in the how-to or fix it in scaffold.ts so it is not silent.
Pushed to the branch. tsc, scripts/gate.ts and the two test files I touched (test/script-errors.test.ts, test/security-controls.test.ts) pass. I didn't run the rest of the suite; the forge's check does that.
- Terminal door: the Security controls screen (
src/tui/screens/controls.ts) now lists script errors below the policy rows. Each row has the page, the message,script:lineand the count over 7 days, and is empty when there are none. It sits behind the same admin-only gate as the page, and a new test covers the rows and that gate. It is read-only, like the rest of that screen, so I did not add a live refresh. - How-to:
docs/features/csp-reports.mdhas a new "Script errors" section. It covers what is reported, what is refused, what is kept (30 days), where it shows (/security, the terminal, and MCPsystemkindcsp), and the opt-outs. Nothing went in the README. - Beacon comment: reflowed so the script-error sentence and the referrer sentence are separate.
- DNT test:
test/script-errors.test.tsnow checks thatdoNotTrack"1" and theforge_measure=offcookie each leave the beacon with no listeners registered and nothing sent. - Spawned apps: I did not fix this in
src/apps/scaffold.ts. Their beacon posts script errors to the app's own/_script-error, which the forge proxy doesn't carry, so those errors aren't counted. The how-to says so under "Not for spawned apps". A fix would mean adding a proxy door alongside/__forge/measure, which seemed beyond this pull.
🤖 Generated with Claude Code
Earlier asks met: the terminal door (admin-only rows on the Security controls screen, tested), the how-to in docs/features/csp-reports.md (nothing in README), the beacon comment, and the DNT/forge_measure=off test. On the preview, a same-origin report answers 204 and a cross-origin Origin answers 403. The body is capped by bytes read, the shape is exact, the page must be a served route, rate limits key on the socket bucket, and output is escaped. Non-blocking: dynamic routes accept any page path, so a stranger can fill the 500 daily rows and fold real errors into (more). Non-blocking: spawned apps do not count errors; the how-to says so.
The beacon now reports uncaught script errors, the server counts them, and
/securityand the MCP CSP answer list them.tsc, the gate, the design lint,mcp-sizeand the affected tests pass. I did not run the whole suite; the forge does that on push. I did not look at/securityin a browser or try the beacon in a real page. The beacon's 5-per-view cap is tested against its source with stubbed browser globals.What changed
src/web/beacon.ts):- It listens for
errorandunhandledrejectionand sends at most 5 per page view to/_script-error. - Each report carries the page path, the script's path and line, and the message cut to 120 characters. - The script path is same-origin only and has no query. An error from a cross-origin script is not sent. An inline error sends an empty script path. - It honours Do-Not-Track andforge_measure=offlike the rest of the beacon, and is about 1.5 KB.src/http/script_errors.ts, route inserver.ts):- Checks: the Origin must match, opted-out visitors are stored nothing, the body is at most 2 KB, and the shape must be exactly
{page, src, line, msg}or the answer is a 4xx. The page must be a route the forge serves. - Message: only its first line is kept, control characters are replaced, and it is cut to 120 characters. - Counting: one row per page + script + line + message per UTC hour, 500 rows a day with the rest folded into one, days past 30 dropped, and callers rate limited. - The table comes from a new additive migration (20261111160000-script-errors), already pinned./security: a "Script errors on pages, 7 days" row lists page, where, message, count and last seen. It is hidden when there are none.system{kind: "csp"}now also returnsscriptErrorsfor the last 24 hours. It is the same tool and kind with a few extra doc words, so no new tool and the ceilings are unchanged.- New (
test/script-errors.test.ts): a well-formed report is counted; an oversized, wrong-type, cross-origin or malformed one stores nothing; a page view sends no more than 5. - Extended (test/mcp-csp-violations.test.ts): the exact MCP answer shape now includesscriptErrors, which that test asserted exactly and would otherwise fail.Left out
Try it: load any page that throws, then open
/securityas an admin.Closes #11447