chore: back-merge main into dev (keeps the CSP fix) - #2545
Conversation
The report-only CSP has been posting violations straight to Sentry's security endpoint, which put ~70k events across ~1.8k users into peanut-ui in a week and buried real signal. Those reports never pass through the Sentry browser SDK, so no beforeSend/shouldIgnoreError filter could ever have touched them — the only place to filter is an endpoint we own, so reports now go to a same-origin collector that forwards to Sentry minus the noise. The noise is overwhelmingly repetition: Sentry groups CSP issues by directive + blocked origin, so one missing allow-list entry generated 14k identical events. The collector forwards the first sighting of each group unconditionally — a genuinely new violation still creates its issue and fires its alert — and samples the repeats. Separately, connect-src was missing wss://api.peanut.me. CSP treats wss: as a scheme distinct from https:, so neither https://api.peanut.me nor https://*.peanut.me authorized the charges websocket every logged-in session opens. That single gap is 84% of the violations still firing, and it would have broken the socket for every user the moment the policy was promoted to enforcing. The Sumsub WebSDK's liveness socket had the identical scheme trap, and the Bridge terms-of-service iframe was missing from frame-src, which would have dead-ended KYC.
Chromium prefers report-to and ignores report-uri whenever both are present, so an endpoint value it declines to parse would cost us every Chromium report — silently, with no error anywhere to notice it by. The spec says an endpoint URL is resolved against the document, but MDN and Chrome's own docs both describe the value as absolute. An absolute URL is valid under either reading, so stop betting on the ambiguity. report-uri keeps the relative path, which is unambiguously a URI-reference and stays correct on preview origins too.
Flagged by the code-analysis bot: the exported route handler had no return type annotation.
…p fan-out Two defects found reviewing the collector against Sentry's actual ingest behaviour. The group key was coarser than Sentry's csp:v1 grouping strategy, which drops the blocked URI and keys on the keyword when script-src is violated by 'unsafe-inline' or 'unsafe-eval'. Sentry raises two issues there; the key collapsed them into one, so whichever arrived second looked like a duplicate and was sampled away — its issue possibly never created. Those two keywords are the top of this policy's "tighten before enforcing" list, so that was exactly the signal the filter must not eat. The forward loop was also uncapped. The endpoint is unauthenticated and a Reporting-API payload is an array, so one POST could fan out arbitrarily many events — and Sentry bills CSP reports against the *error* quota under a per-key rate limit, making an uncapped fan-out a way to starve real exception reporting rather than merely noisy.
Reverts the absolute Reporting-Endpoints URL from 2a5fda9. That change was made on the strength of MDN describing the value as absolute, but the Reporting API spec resolves an endpoint "with base URL set to response's url" (W3C Reporting §3.3) — root-relative is valid and is resolved against whichever origin served the page. Relative is also strictly safer here. An absolute URL has to come from NEXT_PUBLIC_BASE_URL, which is unset on preview deploys, so previews would have pointed at production's collector — cross-origin, and Chromium ignores report-uri whenever report-to is present, so those reports would have been dropped rather than falling back.
…amily Both from CodeRabbit review. The fan-out cap ran after the de-duplication filter, but shouldForward records every group it inspects as seen. Truncating afterwards meant groups past the cap were marked seen without ever being sent, so their real first sighting was lost and later repeats were sampled away as duplicates — the exact failure the first-sighting guarantee exists to prevent. Cap the candidates first, then de-duplicate. The keyword grouping also only matched a bare `script-src`, but browsers report the sub-directive they actually checked: inline <script> arrives as script-src-elem and inline handlers as script-src-attr, with only eval left under plain script-src. script-src-elem is the second-largest directive in the current reports, so this branch was mostly not firing.
Numbers predated the Sentry query: ~70k events / ~1.8k users over 7d, not 65k/1.3k over 4d.
charges-ws pushes {uuid,status} pings on charge completion and expects
clients to refetch /users/history. The FE rendered them as full history
entries — with no extraData.kind the transformer falls back to a generic
strategy, so completed withdrawals/request-pays flashed as 'Sent to
Transaction $0.00 · Completed' on home/history until a refetch, and
HomeHistory's uuid-keyed merge could even overwrite the real fetched row
with the ping (PEANUT-UI-QCW, 104 users; TASK-20808).
The /history page's per-entry callback used to invalidate the balance query on every WS entry; kindless pings no longer reach callbacks, so the shared guard refreshes balance itself — completions move balance.
Several mounted useWebSocket instances see the same ping; TanStack's default cancelRefetch=true made each invalidation abort and restart the same /users/history fetch.
createOnrampForGuest called POST /bridge/onramp/create-for-guest, the unauthenticated route removed in peanut-api-ts #1247. Nothing in the UI ever called it — only its own two tests did — so this is dead code either way, but peanut-ui is a public repo and leaving it here keeps advertising the calling pattern it exploited: userId in the body, no auth header. Deleting the function orphans CreateOnrampGuestParams and three imports (CountryData, getCurrencyConfig, getCurrencyPrice), all of which existed solely to serve it. cancelOnramp and its serverFetch import are untouched. Generated API types are deliberately left alone: sync-openapi.yml pulls from staging on a weekday cron and owns them, and staging will not carry the route removal until the main->dev back-merge lands. Reported by Gibran, 27 Jul 2026.
…ch-timeouts fix(fetch): context-aware fetch timeouts — stop applying a stale Vercel limit to browsers
Companion to peanut-api-ts #1247, which closes the PR #644 authorization fallout. The API now refuses to stream anything on /ws/charges/:username until the client proves who it is. The socket carries charge activity, Bridge/Manteca/Sumsub KYC status - including rejectLabels - and Rain card balance changes, and it was keyed on a public username with no credential at all. The frontend consumes those payloads (useWebSocket passes rejectLabels straight to the KYC handlers), so stripping them was not an option; the fix has to be auth. PeanutWebSocket now sends {type:'auth', token} as its first frame and the server subscribes to nothing until it verifies. A message frame rather than a header or query param because the browser WebSocket API cannot set headers, the jwt-token cookie is host-only on peanut.me and so never reaches api.peanut.me, and a token in a URL would land in every access log in between. It also suits native: getSessionTokenForSocket reads the token from the Capacitor cookie jar asynchronously, which a post-open frame can wait for. That helper is a deliberate, single-caller exception to "JS is not a token custodian on native" - the WebView's own WebSocket cannot see CapacitorHttp's jar, so nothing else can carry the credential. An auth rejection arrives as a non-clean close, which would otherwise fall into the 5x exponential-backoff loop and hammer the API with a credential already known to be bad. Close codes 4401/4408 and the auth_error frame now short-circuit that. HomeHistory subscribed to the `username` PROP, which on a public profile is the profile owner - so a logged-in viewer opened a socket on a stranger's channel and received their KYC and card events. It now always subscribes to the signed-in user's own channel, matching every other useWebSocket caller. The visible list is unchanged: it still comes from useTransactionHistory keyed on the viewed username with filterMutualTxs, which is what makes a profile show transactions mutual to the viewer. API types regenerated from the #1247 branch's openapi.json. Beyond the routes that PR deletes, this picks up four the committed spec had drifted behind (auth/step-up/*, unsubscribe, users/logout) - types only.
…268e5d) Bumps src/content on production (main) from 1078c28 → c268e5d (peanut-content latest = mono@0b0740a). Single-file submodule pointer change. Publishes content already merged + mirrored from mono that the dev-targeted auto-PRs never promote to main.
…202607271917 content: publish latest to production (src/content → peanut-content@c268e5d)
…onnect on the no-session path Two real bugs from the previous commit, both caught by CodeRabbit. Switching the socket to the signed-in user's own channel stopped the cross-user leak, but the merge downstream was left untouched - so on someone else's profile the VIEWER's own live transactions were still folded into the list being rendered, which is the profile owner's history. Derive liveEntries, empty unless viewing your own history, and merge that instead. The no-session branch emitted 'connect' (the socket did open), then 'auth_error', then called disconnect(). But disconnect() nulls onclose before closing, so handleClose - the only other place that emits 'disconnect' - never ran. useWebSocket maps those two events straight onto its status, so a client with no session sat there reporting "connected" forever with no reconnect scheduled to correct it. Emit 'disconnect' explicitly on that path.
Content merged to mono never reached peanut.me on its own. update-content.yml opened its submodule-bump PRs against `dev`, but content only ever flows main->dev via the back-merge, so `dev` was never ahead of `main` on `src/content` and a release could not carry a bump up. 29 auto-PRs were opened against `dev`; not one merged. Every publish was a manual signed PR to `main`. Retarget the auto-PR to `main` and let content-publish-automerge.yml own the gate. Because update-content.yml authors as CONTENT_BOT_TOKEN's user and GitHub forbids self-approval, the existing approve-then-auto-merge path can never complete for these PRs — so add a workflow_run fallback that merges via the org-admin bypass. That bypass skips the required status check as well as the review, so the fallback re-establishes green `ci-success` itself before merging: at least one ci-success check run on the exact head commit, all of them green, diff exactly `src/content`, PR open, non-draft, same-repo. Fail-closed throughout.
…action feat(security): authenticate the charges websocket + drop the orphaned guest onramp action
…tch drops the update A fetch already running when the ping arrives snapshotted state before the charge/claim committed; cancelRefetch:false let it satisfy the invalidation without the new row (stuck until the next 30s poll). Abort-restart is the correct edge-case behavior (jjramirezn).
Two failure modes the retarget would otherwise move from `dev` onto `main`. A burst of content pushes dispatches several runs of update-content.yml. Each branches off the same `main` and opens its own PR; once the first merges, the rest conflict on the `src/content` gitlink and can never merge. Serialise with a concurrency group so the newest dispatch wins — it checks out peanut-content's tip, so it already contains whatever the cancelled runs would have published. Any auto-PR left open is then dead weight, so close superseded ones on open and delete their branches. This is a chore that was already being done by hand: all 29 auto-PRs ever opened against `dev` were closed manually, one at a time, none merged. Best-effort — a failure to close must not fail the publish just opened.
MDX is code. An expression in a content file compiles straight through to
executable JavaScript — `{require("fs").readFileSync("/etc/passwd")}` survives
compilation verbatim — and then runs wherever the page renders: the CI runner
during `next build`, and the production build on Vercel. Both carry secrets.
That was tolerable while a human looked at every content publish. The preceding
commits remove that human: content-only bumps now merge to production on green
CI alone, on the premise that content cannot do anything dangerous. Enforce the
premise rather than assume it.
The check is on *executable*, not on braces. Content uses two inert forms that
must keep working — `{/* editor comments */}` and literal props like
`<Step number={1} />` — so an expression is allowed when its parsed program is
empty or a single literal, and rejected when it can call, read or reference.
Imports, exports and JSX spreads have no inert form and are always rejected.
Fail-closed: an expression whose program cannot be parsed is rejected.
Verified against the real compiler: 7 attack payloads rejected (fs read, env
exfil, spread, import, IIFE, template literal, bare identifier), and all 758
files under src/content compile unchanged. Content has 17 expressions in total —
15 literals and 2 comments — and no executable JavaScript anywhere, so this
removes nothing that is in use.
Auto-publishing removed the human who used to notice. It did not add anything to notice in their place: a broken internal link fails `validate-links`, the publish PR never merges, and nothing goes red anywhere — the content simply never appears until somebody spots a stale page. Assert the end-to-end invariant rather than instrumenting each failure: is production serving the content that is on mono `main`? That one question covers a dead mirror, a dispatch that never fired, a failed update-content run, red CI, a conflicted PR, and whatever else we have not thought of. It lives in peanut-ui because peanut-ui already holds tokens for all three repos (MONO_READ_TOKEN, SUBMODULE_TOKEN) and mono has no secrets at all. Checking hop 1 matters: comparing only peanut-content against peanut-ui would look perfectly consistent while the mirror was stuck and mono raced ahead. Loud means loud. A stuck pipeline always fails the run — red in Actions, and re-failing every 15 minutes so it cannot scroll away — and additionally posts to Discord when CONTENT_PIPELINE_DISCORD_WEBHOOK is set. A missing webhook degrades the alert; it never silences it. Red CI skips the grace period, since it will never resolve on its own.
Cloudflare 403s Discord webhook posts made with python-urllib's default User-Agent. Caught by actually firing the webhook rather than trusting it: the alert path would have failed silently in CI, which is precisely the failure this watchdog exists to prevent.
I claimed this closed a live RCE. It does not. next-mdx-remote@6 already strips
the same node set by default — removeJavaScriptExpressions via blockJS, and
removeImportsExportsPlugin via useDynamicImport:false — so the production path
was never exposed. My proof compiled through raw @mdx-js/mdx, which is not what
renderContent calls.
The guard still earns its place, for smaller and truer reasons: it pins an
invariant that is currently only a dependency default on a path that publishes
to production unreviewed, and because it runs first in remarkPlugins it throws
where next-mdx-remote silently strips — so '{price}' written by an author
becomes a failed build instead of a page that quietly renders nothing.
The Android, iOS and demo-mode runbooks were consolidated into mono at engineering/native/ so there is one source of truth. The copies here had already drifted and now state wrong facts (Capgo deploy triggers, a secret name that no longer exists, the wrong Sumsub plugin package), so a reader landing on them gets misled. Kept as pointers rather than deleted because three files in the release pipeline still reference these paths. Paths are repo-relative, not github.com URLs, so they also resolve for readers who only have a partial copy of mono.
…o-main ci(content): publish content to production automatically
…publish The watchdog's first live run cried wolf and would have done so every 15 minutes forever. It compared mono's latest commit touching `content/` against the sha stamped in peanut-content's "mirror: sync from mono@<sha>" tip. But the mirror rsyncs `content/` with `--exclude='_system/'` (plus `_system/generated/` separately), so a commit touching only `content/_system/**` is correctly never published. The comparison read that as the mirror being stuck. d9a33de — a docs edit to content/_system/ARCHITECTURE.md — tripped it within minutes of merge. The stamp can't answer "is the mirror healthy" because it only ever names commits that happened to touch mirrored paths. Ask something that has a real answer: did the mirror's last completed run succeed? A run that published nothing is healthy; one that errored is not. A broken mirror also skips the grace period, since it will not fix itself. Verified against the same live state that produced the false alarm: now reports CURRENT and stays silent.
…ty scripts Our own script-src contains 'unsafe-inline' and 'unsafe-eval', and Firefox/Safari echo the entire source list back in violated-directive. The keyword branch matched that text without checking the violation was local, so EVERY script-src report — including a genuinely blocked third-party script — keyed as the ubiquitous inline violation and got sampled away at 1%. That is the exact issue-loss the de-duplication was written to prevent, in the directive that matters most for XSS. Gate the branch on a local blocked-uri and match the quoted keyword, as Sentry's csp:v1 strategy does. Normalize the directive to its first token too: Firefox omits effective-directive, so keys were embedding the whole policy and churning on every edit. Also, forwarding through our own route made Sentry attribute every event to one Vercel egress IP running undici, deleting the browser/user dimension these reports are read for — pass the original User-Agent and X-Forwarded-For through. De-duplicate within a batch so repeats of one violation cannot crowd a new group out of the per-request cap, and evict the oldest seen group instead of dumping all 500 at the ceiling.
…d-report-noise fix(csp): close the wss allow-list gap and stop the Sentry report flood
…ters-main docs(native): point the three native runbooks at mono (main)
…-filter fix(watchdog): stop alerting on content the mirror is never meant to publish
fix(ws): don't render minimal charge-completion pings as $0.00 history rows
… faults The first fix traded one false alarm for another. Checking the mirror's run status via the Actions API is the natural question to ask, but MONO_READ_TOKEN carries contents:read only and 403s on actions:read — so the step died mid-run under `bash -e` and surfaced as a bare red run, indistinguishable from a real content alert. I had dry-run it with my own token, which has both scopes. Answer the same question with contents:read: walk back through mono's recent `content/` commits and take the newest one that touched a path the mirror actually publishes (content/** minus _system/, plus _system/generated/), then compare that against the "mirror: sync from mono@<sha>" stamp. Verified against live history — it skips d9a33de and 539b81f, both content/_system/ only, and lands on 0b0740a, which matches the stamp. Silent, correctly. Two hardening changes from the same incident: Every API call is now `|| true`. The step shell is `bash -e`, so any unguarded non-zero aborts the script at that line and the operator sees a red run with no message explaining it. "Cannot evaluate" is now its own labelled state — WATCHDOG DEGRADED — rather than looking identical to "content is stuck". A watchdog that cannot watch is still worth waking someone for, but not for the wrong reason.
fix(watchdog): use a scope the token actually has, and label watchdog faults
# Conflicts: # src/content # src/utils/auth-token.ts
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Code-analysis diffPainscore total: 6633.7 → 6656.29 (+22.59) 🆕 New findings (387)
…and 367 more. ✅ Resolved (383)
…and 363 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Summary
Back-merge
main→dev.mainhad drifted 34 commits ahead ofdev, and the most important of those is the CSP fix. Without this back-merge, the nextdev→mainrelease would revert that fix and re-open the gap that breaks the card page's charges WebSocket the moment CSP is promoted to enforcing.Merge commit only — no new work.
What came across from
mainnext.config.js:wss://api.peanut.me+wss://*.peanut.meinconnect-src(the scheme trap that makes/cardthe loudest violation in Sentry),wss://*.sumsub.com,https://*.bridge.xyzinframe-src, plushttps://api.coingecko.com,https://www.googletagmanager.com, andhttps://*.google-analytics.com(wildcarded for GA4 region sharding).src/app/api/csp-report/route.ts+src/utils/csp-report.utils.ts(+ tests), withreport-uri /api/csp-reportand the matchingReporting-Endpointsheader.sentryCspReportUri()is gone.fix(security): removed the orphaned guest onramp server action (createOnrampForGuest, which called the unauthenticatedPOST /bridge/onramp/create-for-guestroute deleted in peanut-api-ts feat: handle onesignal push notification initialization #1247) and its two tests.src/lib/mdx-security.ts+ tests, wired intosrc/lib/mdx.ts.getSessionTokenForSocket()insrc/utils/auth-token.ts, consumed bysrc/services/websocket.ts/src/hooks/useWebSocket.ts(+useWebSockettests) — the charges socket cannot use a header or the host-only web cookie, so the token is handed over as the first frame.content-pipeline-watchdog.yml, updates tocontent-publish-automerge.ymlandupdate-content.yml.src/types/api.openapi.json,src/types/api.generated.ts) —devhad not touched these since the merge base, somain's newer snapshot applies cleanly.src/components/Home/HomeHistory.tsxtweaks.Conflict resolutions
Two conflicts. Everything else auto-merged.
1.
src/utils/auth-token.ts— genuine semantic conflict, resolved towarddev's model.The two branches hold different answers to native token custody:
main: "JS is never a token custodian on native" — the CapacitorHttp native cookie jar is the credential store;getAuthToken()returnsnullon native.dev(newer): the jar's Android GET proxy stalls (PEANUT-UI-R44), so the token lives in native Preferences, hydrated into an in-memory cache and sent as anAuthorizationheader, with the jar kept only as a fallback for older cookie-auth binaries.Git auto-merged every function body to
dev's model; the only textual conflict was the doc comment straddlingmain's newgetSessionTokenForSocket()anddev's rewrittenhasNativeSession()comment. Resolution:getSessionTokenForSocket()todev's model: it nowawait authReady()and returns the hydratednativeTokenfirst, falling back to the CapacitorHttp jar. Takingmain's body verbatim would have read only the jar, which underdevis no longer where new native sessions are stored — the socket would have silently failed to authenticate on every current native build. This mirrorshasNativeSession()exactly (cache first, jar fallback).main, false ondev).2.
src/contentsubmodule — tookmain's pointer (strictly newer).Git refused the submodule merge ("commits don't follow merge-base"). Pointers:
1078c28(2026-07-27 16:20)dev:e3a05f7(2026-07-15) —devhad regressed the pointer ~12 days behind the merge basemain:c268e5d(2026-07-27 19:02)c268e5dis a descendant of both, so takingmain's pointer is a pure fast-forward that loses nothing and un-doesdev's accidental regression. (Worth a separate look at howdev's content pointer went backwards — likely a stale base on a long-lived branch.)Verification that the CSP fix survived
dev's ownnext.config.jswork (thecampaign/campaignTag/code→/inviteredirects) is preserved — the merged file differs frommainonly by that block.Local gate
pnpm prettier --check .✅npm run typecheck✅ clean (needed a realpnpm installin the worktree; the sharednode_modulespredatesdev'snext-intl/ capacitor deps and produces phantomTS2307s)npm test✅ 174 suites, 2303 passed, 3 skippednpm run build— see checksRisk
Merge-only, but two behaviour deltas reach
dev:getSessionTokenForSocket()on native now prefers the Preferences-backed cache over the cookie jar. Required for the charges socket to authenticate at all underdev's auth model.devpicks upmain's API type snapshot.devhad not modified it, so no route definitions are lost.Screenshots: N/A (no visible change).