diff --git a/.github/workflows/content-pipeline-watchdog.yml b/.github/workflows/content-pipeline-watchdog.yml new file mode 100644 index 0000000000..c230a0c79f --- /dev/null +++ b/.github/workflows/content-pipeline-watchdog.yml @@ -0,0 +1,165 @@ +name: Content pipeline watchdog + +# Content publishes to production with no human in the loop. The failure mode +# that creates is silence: a broken internal link fails `validate-links`, the +# publish PR never merges, and nothing anywhere goes red — the content simply +# never appears and nobody finds out until someone notices the site is stale. +# +# This asserts the end-to-end invariant instead of instrumenting each failure: +# +# is production's content the same as the content on mono `main`? +# +# One question, every failure mode — mirror died, dispatch never fired, +# update-content errored, CI red, PR conflicted, and whatever we haven't +# thought of yet. +# +# It lives here rather than in mono because peanut-ui already holds tokens to +# read all three repos (MONO_READ_TOKEN, SUBMODULE_TOKEN); mono has no secrets. +# +# Loud by construction: a stuck pipeline ALWAYS fails the run (red in Actions), +# and additionally posts to Discord when the webhook is configured. A missing +# webhook degrades the alert; it never silences it. + +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +# How long the pipeline may legitimately be mid-flight. Mirror ~1min + CI ~5min +# + Vercel ~8min lands around 15-20min in practice; 45 leaves room for a queue +# without letting a real stall hide for long. +env: + GRACE_MINUTES: 45 + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Is production's content current? + env: + UI_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTENT_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + MONO_TOKEN: ${{ secrets.MONO_READ_TOKEN }} + DISCORD_WEBHOOK: ${{ secrets.CONTENT_PIPELINE_DISCORD_WEBHOOK }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -uo pipefail + + # Every API call is `|| true`: the step shell is `bash -e`, so an + # unguarded 403 aborts mid-script and surfaces as a bare red run + # indistinguishable from a real content alert. That happened once + # (actions:read 403). Collect what we can, then decide explicitly. + + # ---- hop 3: what production is serving ----------------------- + LIVE=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/contents/src/content?ref=main" --jq '.sha' || true) + + # ---- hop 2: what the mirror has published -------------------- + TIP=$(GH_TOKEN=$CONTENT_TOKEN gh api repos/peanutprotocol/peanut-content/commits/main --jq '.sha' || true) + TIP_MSG=$(GH_TOKEN=$CONTENT_TOKEN gh api repos/peanutprotocol/peanut-content/commits/main --jq '.commit.message' 2>/dev/null | head -1 || true) + TIP_AT=$(GH_TOKEN=$CONTENT_TOKEN gh api repos/peanutprotocol/peanut-content/commits/main --jq '.commit.committer.date' || true) + + # ---- hop 1: is the mirror itself healthy? -------------------- + # Deliberately NOT a SHA comparison against the "mirror: sync from + # mono@" stamp. The mirror publishes content/ MINUS _system/ + # (plus _system/generated/), so a mono commit touching only + # content/_system/** is correctly never published — and a SHA + # comparison reads that as permanent staleness and alerts every 15 + # minutes forever. That is exactly what happened on the first live + # run (d9a33de, a docs edit to content/_system/ARCHITECTURE.md). + # + # Instead, find the newest mono commit that touched a path the + # mirror actually publishes, and compare THAT against the stamp. + # Reading the Actions API for the mirror's run status would be + # simpler but MONO_READ_TOKEN has contents:read only — it 403s on + # actions:read, which is how the first attempt at this fix failed. + EXPECTED="" + for SHA in $(GH_TOKEN=$MONO_TOKEN gh api \ + "repos/peanutprotocol/mono/commits?path=content&per_page=15" --jq '.[].sha' || true); do + FILES=$(GH_TOKEN=$MONO_TOKEN gh api "repos/peanutprotocol/mono/commits/$SHA" --jq '.files[].filename' || true) + # Published: content/** except content/_system/**, plus content/_system/generated/** + PUBLISHED=$( { printf '%s\n' "$FILES" | grep '^content/' | grep -v '^content/_system/' + printf '%s\n' "$FILES" | grep '^content/_system/generated/'; } | head -1 ) + if [ -n "$PUBLISHED" ]; then EXPECTED="$SHA"; break; fi + done + + MIRRORED=$(printf '%s' "$TIP_MSG" | sed -nE 's/.*mono@([0-9a-f]+).*/\1/p') + + echo "expected mono@${EXPECTED:0:7} | mirrored @${MIRRORED:-none} | peanut-content ${TIP:0:7} | live ${LIVE:0:7}" + + # Cannot evaluate == broken watchdog, which must NOT look like a + # broken pipeline. Label it distinctly so nobody debugs the wrong + # thing, but still be loud — a watchdog that can't watch is an + # outage of the thing that tells us about outages. + if [ -z "$EXPECTED" ] || [ -z "$MIRRORED" ] || [ -z "$LIVE" ] || [ -z "$TIP" ]; then + echo "::error::WATCHDOG DEGRADED — could not evaluate the pipeline (expected=${EXPECTED:0:7} mirrored=${MIRRORED:-none} live=${LIVE:0:7} tip=${TIP:0:7}). This is a watchdog fault, not necessarily a content fault." + DEGRADED=true + else + DEGRADED=false + fi + + STALE="" + MIRROR_BROKEN=false + if [ "$DEGRADED" = true ]; then + STALE="**the watchdog itself could not evaluate the pipeline** — check its permissions before assuming content is stuck" + MIRROR_BROKEN=true + elif [ "${EXPECTED:0:7}" != "${MIRRORED:0:7}" ]; then + STALE="the mirror is behind mono (expected \`${EXPECTED:0:7}\`, published \`${MIRRORED:0:7}\`)" + MIRROR_BROKEN=true + elif [ "$LIVE" != "$TIP" ]; then + STALE="production is behind peanut-content (live \`${LIVE:0:7}\`, latest \`${TIP:0:7}\`)" + fi + + if [ -z "$STALE" ]; then + echo "production content is current." + exit 0 + fi + + AGE=$(( ( $(date -u +%s) - $(date -u -d "${TIP_AT:-now}" +%s) ) / 60 )) + + # ---- which hop, and is it already doomed? -------------------- + NUM=$(GH_TOKEN=$UI_TOKEN gh pr list --repo "$REPO" --state open --base main \ + --json number,headRefName \ + --jq '[.[] | select((.headRefName | startswith("auto/update-content-")) or (.headRefName | startswith("content/publish-to-main")))] | first | .number // empty' || true) + + # A failed mirror will not fix itself, so skip the grace period. + DOOMED=$MIRROR_BROKEN + if [ -n "$NUM" ]; then + PR_SHA=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/pulls/$NUM" --jq '.head.sha' || true) + CI=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/commits/$PR_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name=="ci-success")] | if length == 0 then "pending" elif all(.conclusion == "success") then "green" else "RED" end') + WHERE="publish PR #$NUM is open, ci-success=$CI" + [ "$CI" = "RED" ] && DOOMED=true + else + WHERE="no publish PR is open — the mirror, the dispatch, or update-content.yml failed" + fi + + # Red CI never self-resolves, so don't wait out the grace period. + if [ "$DOOMED" = false ] && [ "$AGE" -lt "$GRACE_MINUTES" ]; then + echo "$STALE — ${AGE}m old, within ${GRACE_MINUTES}m grace ($WHERE). Not alerting yet." + exit 0 + fi + + echo "::error::$STALE — stuck ${AGE}m — $WHERE" + + if [ -n "${DISCORD_WEBHOOK:-}" ]; then + export ALERT=":rotating_light: **Content is not reaching production.** + $STALE + Stuck for **${AGE}m** — $WHERE + Runbook: \`skills/publish-content/scripts/pipeline-status.sh\` + $RUN_URL" + # The User-Agent is load-bearing, not decoration: Cloudflare 403s + # Discord webhook posts sent with python-urllib's default UA. + # Verified 2026-07-28 — without it this silently never alerts. + python3 -c "import json,os,urllib.request; d=json.dumps({'content':os.environ['ALERT']}).encode(); urllib.request.urlopen(urllib.request.Request(os.environ['DISCORD_WEBHOOK'],data=d,headers={'Content-Type':'application/json','User-Agent':'peanut-content-watchdog/1.0'}),timeout=15); print('alerted Discord')" \ + || echo "::warning::Discord post failed — the failed run below is still the alert." + else + echo "::warning::CONTENT_PIPELINE_DISCORD_WEBHOOK is not set — alerting via this failed run only." + fi + + # Always loud, webhook or not. + exit 1 diff --git a/.github/workflows/content-publish-automerge.yml b/.github/workflows/content-publish-automerge.yml index 126526fce3..ecac7aab44 100644 --- a/.github/workflows/content-publish-automerge.yml +++ b/.github/workflows/content-publish-automerge.yml @@ -12,21 +12,36 @@ name: Content publish auto-merge # in force for real code. eslint is already advisory (not in `ci-success`), so # "green tests" here means the real test/typecheck/build jobs, not lint. # +# TWO PATHS TO MERGE, because the approver can't always approve: +# +# 1. `approve-and-merge` (pull_request_target) — approve, then `--auto --merge`. +# GitHub enforces `ci-success` before auto-merge fires. This is the path for +# any publish authored by someone other than CONTENT_BOT_TOKEN's user. +# +# 2. `merge-on-green` (workflow_run) — for publishes that CONTENT_BOT_TOKEN's +# own user authored, where GitHub forbids self-approval so path 1 can never +# complete. Since `update-content.yml` now opens its PRs as that user, this +# is the common path, not the exception. It merges via the org-admin bypass, +# which skips the review requirement (intended — the diff guard replaces it) +# AND the required status check (NOT intended). So this job re-establishes +# green `ci-success` ITSELF before merging. Never merge on the bypass alone. +# # Security model: -# - `pull_request_target` runs THIS logic from the base branch (main), so a PR -# cannot tamper with the guard or the approve step. We never check out or run -# PR code — only `gh` API calls by PR number — so pull_request_target is safe -# here (no untrusted-code execution). -# - Same-repo branches only (fork guard on the job). -# - The approval is submitted by CONTENT_BOT_TOKEN's user (Hugo0). GitHub -# forbids approving your own PR, so this auto-approves anyone's content -# publish EXCEPT one that same user authored — those fall back to a manual -# merge (org-admin bypass). Konrad-authored publishes (the target case) work. +# - `pull_request_target` and `workflow_run` both run THIS logic from the +# default branch (main), so a PR cannot tamper with the guard or the merge +# step. We never check out or run PR code — only `gh` API calls by PR number +# — so both triggers are safe here (no untrusted-code execution). +# - Same-repo branches only (fork guards on both jobs). +# - Both paths require the diff to be EXACTLY `src/content` and are fail-closed: +# any extra file, any `gh` error, any missing check → no merge. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] branches: [main] + workflow_run: + workflows: ['Tests'] + types: [completed] permissions: contents: read @@ -35,7 +50,7 @@ permissions: jobs: approve-and-merge: # Same-repo PRs only — never a fork. - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Guard — diff must be exactly the content submodule pointer @@ -64,13 +79,73 @@ jobs: REPO: ${{ github.repository }} run: | set -euo pipefail - # Approve — satisfies the 1-review rule. Fails only when the bot - # user authored the PR (GitHub blocks self-approval); in that - # case leave it for a manual merge. + # Approve — satisfies the 1-review rule. Fails when the bot user + # authored the PR (GitHub blocks self-approval); `merge-on-green` + # picks those up once CI is green. if gh pr review "$PR" --repo "$REPO" --approve; then # main ruleset allows merge commits only (not squash/rebase). gh pr merge "$PR" --repo "$REPO" --auto --merge \ || echo "::warning::auto-merge not enabled/permitted — merge manually." else - echo "::warning::Could not approve (bot may be the PR author) — merge manually." + echo "::notice::Could not approve (bot authored this PR) — merge-on-green will handle it once ci-success is green." fi + + merge-on-green: + # Self-approval fallback. Fires when `Tests` finishes for any commit; the + # job itself works out whether there's a content publish to merge. + if: github.event_name == 'workflow_run' + runs-on: ubuntu-latest + steps: + - name: Merge content-only publishes once ci-success is green + env: + GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN }} + SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Key off the `ci-success` check run, NOT `workflow_run.conclusion`. + # `ci-success` is the single check the "Protect Prod" ruleset gates + # on; the run conclusion also folds in advisory jobs. Today eslint is + # `continue-on-error: true` so it happens not to move the conclusion — + # but that's an eslint config detail, not a guarantee. Gate on exactly + # what the ruleset gates on. + # + # `Tests` runs on both `push` and `pull_request`, so a commit usually + # carries TWO ci-success check runs. Require at least one and ALL of + # them green — a single red run must block the merge. + RUNS="repos/$REPO/commits/$SHA/check-runs?per_page=100" + TOTAL=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success")] | length') + GREEN=$(gh api "$RUNS" --jq '[.check_runs[] | select(.name=="ci-success" and .conclusion=="success")] | length') + if [ "$TOTAL" -eq 0 ] || [ "$TOTAL" -ne "$GREEN" ]; then + echo "ci-success not green for $SHA ($GREEN/$TOTAL) — nothing to merge." + exit 0 + fi + + # Open, non-draft, same-repo PRs into main still sitting on this exact + # commit. The head.sha match stops us merging a PR that has since + # moved on to an untested commit. + PRS=$(gh api "repos/$REPO/commits/$SHA/pulls" --jq " + .[] + | select(.state == \"open\") + | select(.draft == false) + | select(.base.ref == \"main\") + | select(.head.repo.full_name == \"$REPO\") + | select(.head.sha == \"$SHA\") + | .number") + + if [ -z "$PRS" ]; then + echo "No open content-publish candidates on $SHA." + exit 0 + fi + + for PR in $PRS; do + FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only) + if [ "$FILES" != "src/content" ]; then + echo "::notice::#$PR is not content-only — normal review gate stays in place." + continue + fi + echo "Merging #$PR — diff is exactly src/content, ci-success green ($GREEN/$TOTAL) on $SHA." + gh pr merge "$PR" --repo "$REPO" --merge \ + || echo "::warning::merge failed for #$PR (already merged, or bypass unavailable) — merge manually." + done diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index 528da2f032..0dd48e7166 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -9,18 +9,32 @@ permissions: contents: write pull-requests: write +# A burst of content pushes dispatches several runs. Without this they race: +# each branches off the same `main` and opens its own PR, and once the first +# merges the rest conflict on the `src/content` gitlink and can NEVER merge. +# The newest dispatch wins — it checks out peanut-content's tip, so it already +# contains everything the runs it cancels would have published. +concurrency: + group: update-content + cancel-in-progress: true + jobs: update: runs-on: ubuntu-latest steps: - # Base the bump on `dev` (the PR target), NOT the repo default branch. - # Branching off a different branch than the base is what let unrelated - # code drift leak into the auto-PR (#2226) and made it conflict with dev - # once dev's content pointer moved (#2247). Off `dev`, the PR is - # content-only by construction and always cleanly mergeable. + # Base the bump on `main` — the branch peanut.me actually deploys from. + # This used to target `dev`, which was a dead end: `dev` only ever + # receives content pointers *downward* via the main->dev back-merge, so + # it was never ahead of main and a release could never carry a content + # bump up. 29 auto-PRs were opened against `dev` and not one ever + # merged. Publishing to production was a manual signed PR every time. + # + # Branch off the SAME ref we target, so the PR is content-only by + # construction — branching off a different base is what let unrelated + # code drift leak into the auto-PR (#2226) and conflict (#2247). - uses: actions/checkout@v4 with: - ref: dev + ref: main - name: Init and update submodule env: @@ -41,19 +55,23 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create PR + auto-merge (content-only) + - name: Create PR (content-only) if: steps.check.outputs.changed == 'true' - # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI and auto-merge - # can fire on green checks. Falls back to GITHUB_TOKEN — the PR still - # opens, but GITHUB_TOKEN-created PRs don't trigger CI, so auto-merge - # won't fire until the PAT is configured (no regression vs today). + # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI. Falls back to + # GITHUB_TOKEN — the PR still opens, but GITHUB_TOKEN-created PRs don't + # trigger CI, so nothing would ever gate or merge it. + # + # Approve + merge are deliberately NOT done here: that belongs to + # content-publish-automerge.yml, which runs from `main` (tamper-proof) + # and re-verifies the content-only diff AND green `ci-success` before + # merging. One guard, in one place, on the base branch. env: GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN }} run: | set -euo pipefail BRANCH="auto/update-content-$(date -u +%Y%m%d-%H%M%S)" SUBMODULE_SHA=$(git -C src/content rev-parse HEAD) - PARENT=$(git rev-parse HEAD) # dev tip — bump is content-only vs dev + PARENT=$(git rev-parse HEAD) # main tip — bump is content-only vs main BASE_TREE=$(gh api repos/${{ github.repository }}/git/commits/$PARENT --jq '.tree.sha') # Create tree via API with updated submodule pointer TREE=$(gh api repos/${{ github.repository }}/git/trees \ @@ -64,30 +82,54 @@ jobs: -f "tree[][type]=commit" \ -f "tree[][sha]=$SUBMODULE_SHA" \ --jq '.sha') - # Create signed commit via API (verified signature) + # Create the bump commit via the Git Data API. NOTE: API-created + # commits are UNSIGNED, and `required_signatures` is active on both + # the "All branches" and "Protect Prod" rulesets. This works only + # because CONTENT_BOT_TOKEN belongs to an OrganizationAdmin, who has + # `bypass_mode: always` on both. If that token is ever moved to a + # non-admin account, this step starts failing at ref-creation and the + # bump must be made as a locally signed commit instead. COMMIT_SHA=$(gh api repos/${{ github.repository }}/git/commits \ --method POST \ -f message="Update content submodule to latest main" \ -f "tree=$TREE" \ -f "parents[]=$PARENT" \ --jq '.sha') - # Create branch pointing to signed commit + # Create branch pointing at the bump commit gh api repos/${{ github.repository }}/git/refs \ --method POST \ -f "ref=refs/heads/$BRANCH" \ -f "sha=$COMMIT_SHA" + cat > /tmp/pr-body.md <<'BODY' + Auto-generated: bumps `src/content` to latest peanut-content `main`. + + Based on `main`, so the diff is exactly the submodule pointer. + `content-publish-automerge.yml` approves and merges this once + `ci-success` is green. Anything other than a lone `src/content` + change falls back to the normal 1-review gate. + BODY PR_URL=$(gh pr create \ --head "$BRANCH" \ - --base dev \ - --title "Update content submodule" \ - --body "Auto-generated: bumps the content submodule to latest peanut-content main. Based on dev, so it's content-only and auto-merges once checks pass.") - # Guard: only auto-merge when the PR touches NOTHING but the submodule - # pointer. If anything else slipped in, leave it for a human. - FILES=$(gh pr diff "$PR_URL" --name-only) - if [ "$FILES" = "src/content" ]; then - gh pr merge "$PR_URL" --auto --squash \ - || echo "::warning::auto-merge not enabled/permitted — left for manual merge ($PR_URL)" - else - echo "::warning::PR touches more than src/content — left for manual review:" - echo "$FILES" - fi + --base main \ + --title "content: publish latest to production (src/content → peanut-content@${SUBMODULE_SHA:0:7})" \ + --body-file /tmp/pr-body.md) + echo "Opened $PR_URL" + + # Retire every older auto-PR. They point at stale content, and once + # this one merges they conflict on the gitlink and can never merge — + # they'd just pile up. (29 of them did, on `dev`.) Closing them keeps + # exactly one live publish PR. Best-effort: a failure here must not + # fail the publish we just opened. + NEW="${PR_URL##*/}" + STALE=$(gh pr list --repo "${{ github.repository }}" --state open --base main \ + --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("auto/update-content-")) | .number' \ + || true) + for N in $STALE; do + if [ "$N" != "$NEW" ]; then + echo "Closing superseded #$N" + gh pr close "$N" --repo "${{ github.repository }}" --delete-branch \ + --comment "Superseded by $PR_URL (newer content pointer)." \ + || echo "::warning::could not close #$N" + fi + done diff --git a/next.config.js b/next.config.js index d135d23413..b435a32a43 100644 --- a/next.config.js +++ b/next.config.js @@ -6,29 +6,24 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') /** - * Sentry's CSP-report ingest endpoint, derived from the browser DSN - * (`://@/`). Returns null when the - * DSN is absent or malformed, in which case the policy still ships — it just - * has nowhere to report, which is better than emitting a broken `report-uri`. + * Same-origin collector for violation reports (src/app/api/csp-report). It + * derives Sentry's ingest URL from the DSN and forwards there, dropping the + * noise Sentry would otherwise group into issues nobody can act on. * - * Protocol and any path prefix are preserved: self-hosted Sentry is commonly - * mounted under a sub-path, and flattening one would silently post reports to - * an endpoint that doesn't exist. + * Reporting straight to Sentry — which is what this used to do — is + * unfilterable: the browser POSTs violations itself, so they never pass through + * the Sentry SDK and `beforeSend` never sees them. */ -function sentryCspReportUri() { - const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN - if (!dsn) return null - try { - const { protocol, host, username, pathname } = new URL(dsn) - const segments = pathname.split('/').filter(Boolean) - const projectId = segments.pop() - if (!host || !username || !projectId) return null - const prefix = segments.length ? `/${segments.join('/')}` : '' - return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` - } catch { - return null - } -} +const CSP_REPORT_PATH = '/api/csp-report' + +// Root-relative on purpose, for `report-uri` and `Reporting-Endpoints` alike. +// `report-uri` takes a URI-reference; the Reporting API resolves its endpoint +// "with base URL set to response's url" (W3C Reporting §3.3), so both land on +// whichever origin actually served the page. An absolute URL would have to be +// built from an env var and would silently point preview deploys at +// production's collector — cross-origin, so Chromium would drop those reports +// entirely, since it prefers `report-to` and ignores `report-uri` when both +// are present. Don't "fix" this to an absolute URL. /** * Chain RPC endpoints, which have two independent sources that must both be @@ -71,7 +66,6 @@ const chainRpcHosts = [ * provider serves one subdomain per network. */ function contentSecurityPolicyReportOnly() { - const reportUri = sentryCspReportUri() const directives = [ "default-src 'self'", // PostHog is same-origin via the /relay rewrite, so it needs no entry here. @@ -83,20 +77,37 @@ function contentSecurityPolicyReportOnly() { "connect-src 'self'", 'https://api.peanut.me', 'https://*.peanut.me', + // CSP treats wss: as a scheme distinct from https:, so the two + // entries above do NOT cover the charges websocket + // (NEXT_PUBLIC_PEANUT_WS_URL — wss://api.peanut.me in production, + // wss://api.staging.peanut.me on staging). This gap is what makes + // /card the single loudest violation in Sentry today, and it would + // break the socket for every user the moment the policy is enforced. + 'wss://api.peanut.me', + 'wss://*.peanut.me', 'https://*.ingest.sentry.io', 'https://*.ingest.us.sentry.io', - 'https://www.google-analytics.com', + // Wildcarded because GA4 shards collection by region: EU traffic + // beacons to region1.google-analytics.com, not just www. The same + // sharding is already visible on analytics.google.com below. + 'https://*.google-analytics.com', // GA4 beacons to a region-specific host, and GTM's audience/conversion - // pings go to doubleclick and www.google.com. + // pings go to doubleclick and www.google.com. The GTM container + // itself is fetched as well as executed, so it needs a connect-src + // entry on top of the script-src one. 'https://analytics.google.com', 'https://*.analytics.google.com', 'https://stats.g.doubleclick.net', 'https://www.google.com', + 'https://www.googletagmanager.com', 'https://rpc.zerodev.app', 'https://*.g.alchemy.com', 'https://rpc.ankr.com', 'https://assets.coingecko.com', 'https://coin-images.coingecko.com', + // Token metadata lookup in TransactionDetailsReceipt — a different + // CoinGecko host from the two image CDNs above. + 'https://api.coingecko.com', 'https://api.frankfurter.app', 'https://dolarapi.com', 'https://ipapi.co', @@ -104,11 +115,22 @@ function contentSecurityPolicyReportOnly() { 'https://*.crisp.chat', 'wss://client.relay.crisp.chat', 'https://*.sumsub.com', + // Same scheme trap as api.peanut.me above: the Sumsub WebSDK opens a + // websocket for liveness/video-ident signalling, which the https + // entry does not authorize. Silent in the reports only because + // liveness is a rare path — it would still break under enforcement. + 'wss://*.sumsub.com', 'https://widget.manteca.dev', 'https://*.onesignal.com', ...chainRpcHosts, ].join(' '), - "frame-src 'self' https://client.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la", + // Bridge is the terms-of-service iframe BridgeTosStep renders through + // IframeWrapper — enforcing without it would dead-end KYC. Wildcarded + // like *.sumsub.com because the URL is chosen by the provider at + // runtime (compliance.bridge.xyz today) rather than by us. Crisp is + // wildcarded to match connect-src: the widget pulls attachments from + // assets.crisp.chat, not just client. + "frame-src 'self' https://*.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la https://*.bridge.xyz", "worker-src 'self' blob:", "object-src 'none'", "base-uri 'self'", @@ -119,16 +141,14 @@ function contentSecurityPolicyReportOnly() { // Reporting-Endpoints header below) is what replaces it in Chromium. // Shipping only one would undercount violations and promote the policy on // a partial picture. - if (reportUri) directives.push(`report-uri ${reportUri}`, `report-to ${CSP_REPORT_GROUP}`) + directives.push(`report-uri ${CSP_REPORT_PATH}`, `report-to ${CSP_REPORT_GROUP}`) return directives.join('; ') } const CSP_REPORT_GROUP = 'csp-endpoint' function reportingEndpointsHeader() { - const reportUri = sentryCspReportUri() - if (!reportUri) return [] - return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${reportUri}"` }] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"` }] } // Get git commit hash at build time diff --git a/src/app/actions/__tests__/api-headers-extended.test.ts b/src/app/actions/__tests__/api-headers-extended.test.ts index fa392bf1d0..3809d2de69 100644 --- a/src/app/actions/__tests__/api-headers-extended.test.ts +++ b/src/app/actions/__tests__/api-headers-extended.test.ts @@ -176,17 +176,4 @@ describe('action functions should NOT include apiKey in body', () => { expect(body).not.toBeNull() expect(body).not.toHaveProperty('apiKey') }) - - it('should not include apiKey in createOnrampForGuest body', async () => { - const { createOnrampForGuest } = require('@/app/actions/onramp') - await createOnrampForGuest({ - amount: '100', - country: { id: 'US', name: 'United States', code: 'US' }, - userId: 'user-123', - }) - - const body = getLastCallBody() - expect(body).not.toBeNull() - expect(body).not.toHaveProperty('apiKey') - }) }) diff --git a/src/app/actions/__tests__/api-headers.test.ts b/src/app/actions/__tests__/api-headers.test.ts index 7bc9ebc8ab..02c52dc207 100644 --- a/src/app/actions/__tests__/api-headers.test.ts +++ b/src/app/actions/__tests__/api-headers.test.ts @@ -170,18 +170,6 @@ describe('action functions Content-Type headers', () => { const headers = getLastCallHeaders() expect(headers['Content-Type']).toBe('application/json') }) - - it('should include Content-Type in createOnrampForGuest', async () => { - const { createOnrampForGuest } = require('@/app/actions/onramp') - await createOnrampForGuest({ - amount: '100', - country: { id: 'US', name: 'United States', code: 'US' }, - userId: 'user-123', - }) - - const headers = getLastCallHeaders() - expect(headers['Content-Type']).toBe('application/json') - }) }) // Post-proxy-removal: web calls PEANUT_API_URL directly (same as native). diff --git a/src/app/actions/onramp.ts b/src/app/actions/onramp.ts index 804272a300..dd1d260f99 100644 --- a/src/app/actions/onramp.ts +++ b/src/app/actions/onramp.ts @@ -1,15 +1,5 @@ -import { type CountryData } from '@/components/AddMoney/consts' -import { getCurrencyConfig } from '@/utils/bridge.utils' -import { getCurrencyPrice } from '@/app/actions/currency' import { serverFetch } from '@/utils/api-fetch' -export interface CreateOnrampGuestParams { - amount: string - country: CountryData - userId: string - chargeId?: string -} - /** * Cancel an on-ramp transfer. * @@ -39,41 +29,3 @@ export async function cancelOnramp(transferId: string): Promise<{ data?: { succe return { error: 'An unexpected error occurred.' } } } - -export async function createOnrampForGuest( - params: CreateOnrampGuestParams -): Promise<{ data?: { success: boolean }; error?: string }> { - try { - const { currency, paymentRail } = getCurrencyConfig(params.country.id, 'onramp') - const price = await getCurrencyPrice(currency) - const amount = (Number(params.amount) * price.buy).toFixed(2) - - const response = await serverFetch('/bridge/onramp/create-for-guest', { - method: 'POST', - body: JSON.stringify({ - amount, - userId: params.userId, - chargeId: params.chargeId, - source: { - currency, - paymentRail, - }, - }), - }) - - const data = await response.json() - - if (!response.ok) { - console.log('error', response) - return { error: data.error || 'Failed to create on-ramp transfer for guest.' } - } - - return { data } - } catch (error) { - console.error('Error calling create on-ramp for guest API:', error) - if (error instanceof Error) { - return { error: error.message } - } - return { error: 'An unexpected error occurred.' } - } -} diff --git a/src/app/api/csp-report/route.ts b/src/app/api/csp-report/route.ts new file mode 100644 index 0000000000..3dfcbd5d2d --- /dev/null +++ b/src/app/api/csp-report/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { + cspReportGroupKey, + normalizeCspReports, + sentryCspIngestUrl, + shouldIgnoreCspReport, + type CspReport, +} from '@/utils/csp-report.utils' + +export const dynamic = 'force-dynamic' + +/** + * Collector for the report-only CSP's violation reports. + * + * The policy used to point `report-uri` / `report-to` straight at Sentry's + * security endpoint, which put ~70k events across ~1.8k users into the + * peanut-ui project in a week and buried real signal. Those reports bypass + * the Sentry browser SDK entirely, so no `beforeSend` filter could touch them — + * the only place to filter is an endpoint we own. + * + * This forwards to Sentry exactly as before, minus repeats of a violation + * Sentry has already grouped — one missing allow-list entry produced 14k + * identical events on its own, so de-duplication is the entire lever here. The + * extension-scheme filter in csp-report.utils.ts is a cheap extra that saves an + * outbound request; Sentry's own default inbound filter already drops that + * class server-side, so it is not doing the real work. + */ + +/** Both wire formats, plus plain JSON from anything replaying a report. */ +const ACCEPTED_CONTENT_TYPES = ['application/csp-report', 'application/reports+json', 'application/json'] + +/** + * Sentry groups CSP issues by directive + blocked origin, so every duplicate + * past the first adds quota, not information. Forward the first sighting of + * each group unconditionally — a genuinely new violation always creates its + * issue and fires its alert — then sample the repeats hard. + * + * The Set is per-serverless-instance and resets on cold start, which just means + * an occasional extra first-sighting. Same trade-off the health route's + * in-memory Discord cooldown already accepts. + */ +const DUPLICATE_SAMPLE_RATE = 0.01 +const SEEN_GROUPS_MAX = 500 +const seenGroups = new Set() + +const FORWARD_TIMEOUT_MS = 3000 + +/** + * Hard cap on forwards per request. This endpoint is unauthenticated and a + * Reporting-API batch is an array, so without a cap one POST could fan out + * arbitrarily many events. That matters more than it looks: Sentry bills CSP + * reports against the *error* quota under a per-DSN-key rate limit, so an + * uncapped fan-out could exhaust the quota that real application exceptions + * depend on. A genuine browser batch is a handful of reports. + */ +const MAX_FORWARDS_PER_REQUEST = 20 + +function shouldForward(groupKey: string): boolean { + if (!seenGroups.has(groupKey)) { + // Bound the memory: a flood of distinct groups must not grow forever. + // Evict the oldest rather than clearing — a Set iterates in insertion + // order, so this drops one stale group instead of dumping all 500 and + // letting every still-active violation re-forward at once. + if (seenGroups.size >= SEEN_GROUPS_MAX) { + const oldest = seenGroups.values().next().value + if (oldest !== undefined) seenGroups.delete(oldest) + } + seenGroups.add(groupKey) + return true + } + return Math.random() < DUPLICATE_SAMPLE_RATE +} + +export async function POST(request: NextRequest): Promise { + // Always 204, whatever happens. A non-2xx here makes browsers retry and + // log console errors, which would be a second, self-inflicted noise source. + const noContent = new NextResponse(null, { status: 204 }) + + try { + const contentType = request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() ?? '' + if (!ACCEPTED_CONTENT_TYPES.includes(contentType)) return noContent + + const ingestUrl = sentryCspIngestUrl(process.env.NEXT_PUBLIC_SENTRY_DSN) + if (!ingestUrl) return noContent + + const reports = normalizeCspReports(await request.json()) + + const seenInBatch = new Set() + const forwardable: CspReport[] = [] + for (const report of reports) { + if (shouldIgnoreCspReport(report)) continue + + const groupKey = cspReportGroupKey(report) + // One slot per distinct group: a batch is usually dominated by + // repeats of a single violation, and 20 copies of it must not + // crowd a genuinely new group out of the per-request cap. + if (seenInBatch.has(groupKey)) continue + seenInBatch.add(groupKey) + + // Cap BEFORE shouldForward, never after: shouldForward records + // each group as seen, so admitting more than we can send would + // mark groups seen that were never actually sent — their real + // first sighting lost, every later repeat sampled away. + if (seenInBatch.size > MAX_FORWARDS_PER_REQUEST) break + + if (shouldForward(groupKey)) forwardable.push(report) + } + + // Sentry derives the event's browser and request context from the + // headers of whoever POSTs to its security endpoint. That used to be + // the browser itself; now it is us, so pass the originals through or + // every CSP event is attributed to one Vercel egress IP running + // undici — deleting the "which browser / how many users" dimension + // these reports are read for. + const forwardedHeaders: Record = { 'Content-Type': 'application/csp-report' } + const userAgent = request.headers.get('user-agent') + if (userAgent) forwardedHeaders['User-Agent'] = userAgent + const forwardedFor = request.headers.get('x-forwarded-for') + if (forwardedFor) forwardedHeaders['X-Forwarded-For'] = forwardedFor + + await Promise.allSettled( + forwardable.map((report) => + fetch(ingestUrl, { + method: 'POST', + headers: forwardedHeaders, + body: JSON.stringify({ 'csp-report': report }), + signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS), + }) + ) + ) + } catch { + // Malformed body, unreachable Sentry, timeout — a dropped violation + // report is never worth surfacing an error to the browser. + } + + return noContent +} diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 9d73b217db..01c949a150 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -87,9 +87,20 @@ const HomeHistory = ({ // users who never got a card; only an issued card means they got through. const { overview: rainOverview } = useRainCardOverview() - // WebSocket for real-time updates + // WebSocket for real-time updates. + // + // Always subscribe to the SIGNED-IN user's own channel, never the `username` + // prop. On a public profile that prop is the profile owner, and this used to + // open a socket on their channel — which streamed their charge activity and + // KYC state to whoever was looking. The socket is now authenticated and the + // server rejects a channel that isn't yours, so passing someone else's + // username would simply fail to connect. + // + // The list itself is unaffected: it still comes from useTransactionHistory + // above, keyed on `username` with filterMutualTxs, which is what makes a + // profile show transactions mutual to the viewer. const { historyEntries: wsHistoryEntries } = useWebSocket({ - username, // Pass the username to the WebSocket hook + username: user?.user.username ?? undefined, onHistoryEntry: useCallback( (entry: HistoryEntry) => { const isCompleted = entry.status?.toUpperCase() === 'COMPLETED' @@ -125,6 +136,15 @@ const HomeHistory = ({ ), }) + // Live entries only belong in the list when the list IS the signed-in + // user's. The socket is now always our OWN channel, so on someone else's + // profile `wsHistoryEntries` are the VIEWER's transactions — merging them + // would inject them into the profile owner's history. + const liveEntries = useMemo( + () => (isViewingOwnHistory ? wsHistoryEntries : []), + [isViewingOwnHistory, wsHistoryEntries] + ) + // Combine fetched history with real-time updates const [combinedEntries, setCombinedEntries] = useState< Array @@ -182,7 +202,7 @@ const HomeHistory = ({ // process websocket entries: update existing or add new ones // Sort by timestamp ascending to process oldest entries first - const sortedWsEntries = [...wsHistoryEntries].sort( + const sortedWsEntries = [...liveEntries].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ) @@ -284,7 +304,7 @@ const HomeHistory = ({ } } return undefined - }, [historyData, wsHistoryEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview]) + }, [historyData, liveEntries, user, isLoading, isViewingOwnHistory, cardInfo, rainOverview]) const pendingRequests = useMemo(() => { if (!combinedEntries.length) return [] @@ -351,7 +371,7 @@ const HomeHistory = ({ } // check source data directly — combinedEntries lags behind due to async processing - const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || wsHistoryEntries.length > 0 + const hasSourceEntries = (historyData?.entries?.length ?? 0) > 0 || liveEntries.length > 0 // Synthetic entries (KYC region rows, card-unlock) live in // combinedEntries but NOT in source-side historyData/wsHistoryEntries. diff --git a/src/content b/src/content index e3a05f7771..c268e5d9c4 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit e3a05f77716ca51da8e35880dca34db747a99969 +Subproject commit c268e5d9c436ae742915b26873e4066748883acc diff --git a/src/hooks/__tests__/useWebSocket.test.tsx b/src/hooks/__tests__/useWebSocket.test.tsx new file mode 100644 index 0000000000..8e5632b7cc --- /dev/null +++ b/src/hooks/__tests__/useWebSocket.test.tsx @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useWebSocket } from '@/hooks/useWebSocket' +import { TRANSACTIONS } from '@/constants/query.consts' +import type { HistoryEntry } from '@/hooks/useTransactionHistory' +import type { ReactNode } from 'react' + +// Fake socket capturing event handlers so tests can push server messages. +const handlers: Record void>> = {} +const fakeWs = { + on: (event: string, cb: (data: unknown) => void) => { + ;(handlers[event] ??= []).push(cb) + }, + off: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), +} +jest.mock('@/services/websocket', () => ({ + getWebSocketInstance: () => fakeWs, +})) + +function emitHistoryEntry(entry: Partial) { + act(() => { + handlers['history_entry']?.forEach((cb) => cb(entry)) + }) +} + +function makeWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + Wrapper.displayName = 'TestQueryClientWrapper' + return { wrapper: Wrapper, client } +} + +describe('useWebSocket — history_entry handling', () => { + beforeEach(() => { + for (const key of Object.keys(handlers)) delete handlers[key] + }) + + // Regression for the "Sent to Transaction $0.00" flash (PEANUT-UI-QCW): + // charge completions arrive as minimal {uuid, status} pings with no + // extraData — the BE expects a refetch. Rendering one routes the + // transformer to its fallback strategy (name "Transaction", amount 0). + it('kindless charge ping is not rendered — it invalidates the transactions query instead', () => { + const { wrapper, client } = makeWrapper() + const invalidateSpy = jest.spyOn(client, 'invalidateQueries') + const onHistoryEntry = jest.fn() + + const { result } = renderHook(() => useWebSocket({ username: 'alice', onHistoryEntry }), { wrapper }) + + emitHistoryEntry({ uuid: 'charge-1', type: 'TRANSACTION_INTENT', status: 'COMPLETED' } as HistoryEntry) + + expect(result.current.historyEntries).toHaveLength(0) + expect(onHistoryEntry).not.toHaveBeenCalled() + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [TRANSACTIONS] }) + // Charge completions move balance; the ping must refresh it since the + // per-page callbacks (which used to) no longer see kindless entries. + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['balance'] }) + }) + + it('full entry with a kind is surfaced to state and the callback', () => { + const { wrapper, client } = makeWrapper() + const invalidateSpy = jest.spyOn(client, 'invalidateQueries') + const onHistoryEntry = jest.fn() + + const { result } = renderHook(() => useWebSocket({ username: 'alice', onHistoryEntry }), { wrapper }) + + const entry = { + uuid: 'dep-1', + type: 'TRANSACTION_INTENT', + status: 'COMPLETED', + amount: '200.00', + extraData: { kind: 'CRYPTO_DEPOSIT' }, + } as unknown as HistoryEntry + + emitHistoryEntry(entry) + + expect(result.current.historyEntries).toEqual([entry]) + expect(onHistoryEntry).toHaveBeenCalledWith(entry) + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('pending request entries (NEW, no senderAccount) are still ignored', () => { + const { wrapper } = makeWrapper() + const onHistoryEntry = jest.fn() + + const { result } = renderHook(() => useWebSocket({ username: 'alice', onHistoryEntry }), { wrapper }) + + emitHistoryEntry({ + uuid: 'req-1', + type: 'TRANSACTION_INTENT', + status: 'NEW', + extraData: { kind: 'DIRECT_TRANSFER' }, + } as unknown as HistoryEntry) + + expect(result.current.historyEntries).toHaveLength(0) + expect(onHistoryEntry).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index e1af4fa681..49ca7cfcb6 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { PeanutWebSocket, getWebSocketInstance, @@ -6,6 +7,7 @@ import { type RailStatusUpdate, type RainCardBalanceChangedData, } from '@/services/websocket' +import { TRANSACTIONS } from '@/constants/query.consts' import { type HistoryEntry } from './useTransactionHistory' type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error' @@ -50,6 +52,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { const [status, setStatus] = useState('disconnected') const [historyEntries, setHistoryEntries] = useState([]) const wsRef = useRef(null) + const queryClient = useQueryClient() const callbacksRef = useRef({ onHistoryEntry, @@ -159,6 +162,21 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { const handleHistoryEntry = (entry: HistoryEntry) => { const kind = entry.extraData?.kind + if (!kind) { + // Kindless entries are minimal {uuid, status} pings (BE: + // charges-ws charge completions, claim.ts sendlink claims) — + // the BE expects clients to refetch, not render. Rendering one + // hits the transformer's fallback strategy and shows "Sent to + // Transaction $0.00 · Completed" (PEANUT-UI-QCW). Balance moves + // with these events too, so refresh it alongside the feed. + // Default cancelRefetch (true) on purpose: a fetch already in + // flight when the ping arrives started pre-commit and may lack + // the new row — joining it would clear the invalidation with + // stale data. Abort-restart guarantees a post-event response. + queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + queryClient.invalidateQueries({ queryKey: ['balance'] }) + return + } if ( (kind === 'DIRECT_TRANSFER' || kind === 'P2P_REQUEST_FULFILL') && entry.status === 'NEW' && @@ -255,7 +273,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.off('user_rail_status_changed', handleRailStatusUpdate) ws.off('rain_card_balance_changed', handleRainCardBalanceChanged) } - }, [autoConnect, connect, username]) + }, [autoConnect, connect, username, queryClient]) // Return exposed functionality return { diff --git a/src/lib/__tests__/mdx-security.test.ts b/src/lib/__tests__/mdx-security.test.ts new file mode 100644 index 0000000000..5eeb237402 --- /dev/null +++ b/src/lib/__tests__/mdx-security.test.ts @@ -0,0 +1,133 @@ +import { remarkNoExecutableContent } from '@/lib/mdx-security' + +/** + * Node type names and estree shapes below mirror what @mdx-js/mdx's parser + * actually emits — they were read off the real parser, not guessed. + */ +const plugin = remarkNoExecutableContent() + +const root = (...children: unknown[]) => ({ type: 'root', children }) as never + +/** An expression node carrying a parsed program, the way the real parser emits it. */ +const expression = (type: string, value: string, body: unknown[], line?: number) => ({ + type, + value, + data: { estree: { body } }, + ...(line ? { position: { start: { line } } } : {}), +}) + +const statement = (expr: unknown) => ({ type: 'ExpressionStatement', expression: expr }) +const literal = (value: unknown) => ({ type: 'Literal', value }) + +describe('remarkNoExecutableContent', () => { + describe('rejects executable MDX', () => { + it('rejects a call expression', () => { + const tree = root( + expression('mdxFlowExpression', 'require("fs")', [statement({ type: 'CallExpression' })], 3) + ) + expect(() => plugin(tree)).toThrow(/JavaScript expression.*line 3/s) + }) + + it('rejects a member expression prop', () => { + const tree = root({ + type: 'mdxJsxFlowElement', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'bar', + value: expression('mdxJsxAttributeValueExpression', 'process.env', [ + statement({ type: 'MemberExpression' }), + ]), + }, + ], + }) + expect(() => plugin(tree)).toThrow(/expression prop/) + }) + + it('rejects import/export outright', () => { + const tree = root({ type: 'mdxjsEsm', value: "import fs from 'fs'" }) + expect(() => plugin(tree)).toThrow(/import\/export statement/) + }) + + it('rejects a JSX spread attribute outright', () => { + const tree = root({ + type: 'mdxJsxFlowElement', + attributes: [{ type: 'mdxJsxExpressionAttribute', value: '...process.env' }], + }) + expect(() => plugin(tree)).toThrow(/spread attribute/) + }) + + it('rejects a multi-statement program', () => { + const tree = root( + expression('mdxTextExpression', 'a;b', [statement(literal(1)), statement({ type: 'CallExpression' })]) + ) + expect(() => plugin(tree)).toThrow(/JavaScript expression/) + }) + + it('fails closed when the expression has no parsed program', () => { + const tree = root({ type: 'mdxTextExpression', value: 'whatever' }) + expect(() => plugin(tree)).toThrow(/JavaScript expression/) + }) + + it('finds executable nodes nested deep in the tree', () => { + const tree = root({ + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [expression('mdxTextExpression', 'evil()', [statement({ type: 'CallExpression' })])], + }, + ], + }) + expect(() => plugin(tree)).toThrow(/JavaScript expression/) + }) + }) + + describe('allows the inert forms content actually uses', () => { + it('allows a comment-only expression — {/* … */} parses to an empty program', () => { + const tree = root(expression('mdxFlowExpression', '/* a note to editors */', [])) + expect(() => plugin(tree)).not.toThrow() + }) + + it('allows a literal prop — ', () => { + const tree = root({ + type: 'mdxJsxFlowElement', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'number', + value: expression('mdxJsxAttributeValueExpression', '1', [statement(literal(1))]), + }, + ], + }) + expect(() => plugin(tree)).not.toThrow() + }) + + it('allows a signed literal — {-1}', () => { + const tree = root( + expression('mdxTextExpression', '-1', [ + statement({ type: 'UnaryExpression', operator: '-', argument: literal(1) }), + ]) + ) + expect(() => plugin(tree)).not.toThrow() + }) + + it('allows plain markdown', () => { + const tree = root({ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Fees' }] }) + expect(() => plugin(tree)).not.toThrow() + }) + + it('allows a JSX component with string props', () => { + const tree = root({ + type: 'mdxJsxFlowElement', + attributes: [{ type: 'mdxJsxAttribute', name: 'currency', value: 'ARS' }], + children: [{ type: 'text', value: 'hi' }], + }) + expect(() => plugin(tree)).not.toThrow() + }) + + it('tolerates an empty tree', () => { + expect(() => plugin(root())).not.toThrow() + }) + }) +}) diff --git a/src/lib/mdx-security.ts b/src/lib/mdx-security.ts new file mode 100644 index 0000000000..ab988c25ef --- /dev/null +++ b/src/lib/mdx-security.ts @@ -0,0 +1,118 @@ +/** + * Make it a build error for content to contain executable JavaScript. + * + * Raw MDX compiles expressions straight through to executable JS — + * `{require('fs').readFileSync('/etc/passwd')}` survives verbatim — which would + * run during `next build`, in CI and on Vercel, both of which carry secrets. + * + * We are not exposed to that today, and this module is NOT the thing preventing + * it: `next-mdx-remote@6` already strips the same node set by default, via + * `removeJavaScriptExpressions` (`blockJS`) and `removeImportsExportsPlugin` + * (`useDynamicImport: false`). This exists for two reasons anyway: + * + * 1. That protection is a dependency's *default*, on a path that now publishes + * to production with no human review. A major upgrade, or someone passing + * `blockJS: false` or `useDynamicImport: true` for an unrelated reason, + * re-opens it silently. This asserts the invariant in our own repo, where it + * is visible and tested. + * 2. next-mdx-remote *silently strips*; we run first in `remarkPlugins`, so we + * *throw*. An author who writes `{price}` expecting interpolation currently + * gets a page that quietly renders nothing and auto-publishes. They should + * get a failed build naming the file and line. + * + * So: the value here is failing loud and pinning the invariant, not closing a + * live hole. Do not delete it on the grounds that "the library handles it" — + * that is precisely the assumption it exists to keep honest. + * + * It is deliberately not a blanket ban on `{...}` — content legitimately uses + * two inert forms, and breaking them would just push authors around the guard: + * + * {/* a comment *\/} → compiles to nothing at all + * → a literal; there is no code to run + * + * So the rule is *executable*, not *braced*: an expression is allowed only when + * its parsed program is empty (comment-only) or a single literal. Anything that + * can call, read, or reference — `{process.env}`, `{fn()}`, `{...spread}`, + * `import` — is rejected at compile time. + * + * Fail-closed: if an expression arrives without a parsed program we cannot prove + * it is inert, so it is rejected. + */ + +/** Executable unless proven inert. Node type names verified against @mdx-js/mdx's parser. */ +const GUARDED_EXPRESSIONS: Record = { + mdxFlowExpression: 'a JavaScript expression', + mdxTextExpression: 'a JavaScript expression', + mdxJsxAttributeValueExpression: 'a JSX expression prop (prop={x})', +} + +/** Never legitimate in content — no inert form exists. */ +const FORBIDDEN_NODES: Record = { + mdxjsEsm: 'an import/export statement', + mdxJsxExpressionAttribute: 'a JSX spread attribute ({...x})', +} + +type EstreeNode = { type?: string; operator?: string; argument?: EstreeNode; expression?: EstreeNode } + +type MdxNode = { + type?: string + children?: MdxNode[] + attributes?: MdxNode[] + value?: unknown + data?: { estree?: { body?: EstreeNode[] } } + position?: { start?: { line?: number } } +} + +/** A literal — or a signed literal like `{-1}`, which is still nothing to execute. */ +function isLiteral(expression?: EstreeNode): boolean { + if (expression?.type === 'Literal') return true + if (expression?.type === 'UnaryExpression' && (expression.operator === '-' || expression.operator === '+')) { + return expression.argument?.type === 'Literal' + } + return false +} + +function isInert(node: MdxNode): boolean { + const body = node.data?.estree?.body + if (!Array.isArray(body)) return false // unparsed — cannot prove it is safe + if (body.length === 0) return true // comment-only + if (body.length > 1) return false + const [statement] = body + return statement?.type === 'ExpressionStatement' && isLiteral(statement.expression) +} + +function reject(node: MdxNode, what: string): never { + const at = node.position?.start?.line ? ` at line ${node.position.start.line}` : '' + throw new Error( + `Executable MDX rejected: content contains ${what}${at}. ` + + `Content is published to production without human review, so it must not be able to run code. ` + + `Use markdown, a literal prop, or a registered component.` + ) +} + +function walk(node: MdxNode): void { + if (!node || typeof node !== 'object') return + + const type = node.type as string + if (type in FORBIDDEN_NODES) reject(node, FORBIDDEN_NODES[type]) + if (type in GUARDED_EXPRESSIONS && !isInert(node)) reject(node, GUARDED_EXPRESSIONS[type]) + + for (const child of node.children ?? []) walk(child) + + // JSX attributes are not `children`, so they need their own pass: both the + // attribute node itself (a spread) and its value (an expression prop) can + // carry code. + for (const attribute of node.attributes ?? []) { + walk(attribute) + const value = attribute?.value + if (value && typeof value === 'object') walk(value as MdxNode) + } +} + +/** + * Remark plugin that fails the compile if content contains executable MDX. + * It throws, which aborts the build rather than silently rendering. + */ +export function remarkNoExecutableContent() { + return (tree: MdxNode): void => walk(tree) +} diff --git a/src/lib/mdx.ts b/src/lib/mdx.ts index 2686462ea2..c4dc558d0d 100644 --- a/src/lib/mdx.ts +++ b/src/lib/mdx.ts @@ -1,6 +1,7 @@ import { compileMDX } from 'next-mdx-remote/rsc' import remarkGfm from 'remark-gfm' import { mdxComponents } from '@/components/Marketing/mdx/components' +import { remarkNoExecutableContent } from '@/lib/mdx-security' /** * Compile markdown/MDX content into a React element with registered components. @@ -14,6 +15,9 @@ import { mdxComponents } from '@/components/Marketing/mdx/components' * * Limitation: next-mdx-remote/rsc strips JSX expression props ({...}). * Components that need structured data accept JSON strings instead. + * + * remarkNoExecutableContent — content is published to production without human + * review, so it must not be able to execute. See mdx-security.ts. */ export async function renderContent(source: string) { return compileMDX>({ @@ -22,7 +26,7 @@ export async function renderContent(source: string) { options: { mdxOptions: { format: 'mdx', - remarkPlugins: [remarkGfm], + remarkPlugins: [remarkNoExecutableContent, remarkGfm], }, }, }) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 701779afc2..11e16b6b38 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -2,6 +2,7 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' import { isDemoMode } from '@/utils/demo' import { isCapacitor } from '@/utils/capacitor' +import { getSessionTokenForSocket } from '@/utils/auth-token' export type { PendingPerk } function isValidWsUrl(url: string): boolean { @@ -47,6 +48,9 @@ export type WebSocketMessage = { type: | 'ping' | 'pong' + // Handshake: the server sends one of these in reply to our `auth` frame. + | 'auth_ok' + | 'auth_error' | 'history_entry' | 'kyc_status_update' | 'manteca_kyc_status_update' @@ -64,6 +68,8 @@ export class PeanutWebSocket { private reconnectTimeout: NodeJS.Timeout | null = null private eventListeners: Map void>> = new Map() private isConnected = false + /** Set when the server refused our credential; suppresses the reconnect loop. */ + private authFailed = false private reconnectAttempts = 0 private readonly maxReconnectAttempts = 5 private readonly reconnectDelay = 3000 // 3 seconds @@ -82,6 +88,10 @@ export class PeanutWebSocket { return } + // A caller reconnecting deliberately (e.g. after a fresh login) gets a + // clean slate — the previous credential rejection should not stick. + this.authFailed = false + try { const fullUrl = new URL(this.path, this.url).toString() @@ -133,11 +143,41 @@ export class PeanutWebSocket { } } - private handleOpen(): void { + /** + * The server subscribes this socket to nothing until it receives an `auth` + * frame carrying a session JWT that resolves to the user in the path, so + * send it immediately on open. Async because native reads the token from + * the native cookie jar — which is exactly why auth is a message frame and + * not a header or query param. + */ + private async handleOpen(): Promise { this.isConnected = true this.reconnectAttempts = 0 this.startPingInterval() this.emit('connect', null) + + const token = await getSessionTokenForSocket() + if (!token) { + // No session to present. The server would drop us on its auth + // deadline anyway; close now and do not retry, or we would spin + // reconnecting a socket that can never authenticate. + this.authFailed = true + this.emit('auth_error', { reason: 'no-session' }) + this.disconnect() + // `disconnect()` nulls onclose before closing, so handleClose — the + // only other place that emits 'disconnect' — never runs on this + // path. Emit it here or consumers that track connect/disconnect + // pairs (useWebSocket maps them straight onto its status) stay + // stuck reporting "connected" forever, with no reconnect scheduled + // to correct it. We already emitted 'connect' above: the socket did + // open, we are closing it again. + this.emit('disconnect', { code: 0, reason: 'no-session' }) + return + } + + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type: 'auth', token })) + } } private handleMessage(event: MessageEvent): void { @@ -150,6 +190,20 @@ export class PeanutWebSocket { this.emit('pong', null) break + case 'auth_ok': + // Handshake accepted — event frames start flowing now. + this.authFailed = false + this.emit('auth_ok', null) + break + + case 'auth_error': + // The credential was refused, not the transport. Reconnecting + // would present the same bad token again, so mark it + // permanent; handleClose reads this to skip the backoff loop. + this.authFailed = true + this.emit('auth_error', message.data ?? null) + break + case 'history_entry': if (message.data) { // Process history entry @@ -210,11 +264,29 @@ export class PeanutWebSocket { } } + /** Server-side auth close codes (see peanut-api-ts routes/charges-ws). */ + private static readonly CLOSE_AUTH_FAILED = 4401 + private static readonly CLOSE_AUTH_TIMEOUT = 4408 + private handleClose(event: CloseEvent): void { this.isConnected = false this.clearPingInterval() this.emit('disconnect', { code: event.code, reason: event.reason }) + const authRejected = + this.authFailed || + event.code === PeanutWebSocket.CLOSE_AUTH_FAILED || + event.code === PeanutWebSocket.CLOSE_AUTH_TIMEOUT + + // An auth rejection arrives as a non-clean close, so without this check + // it would fall straight into the 5x exponential-backoff loop and + // hammer the API with a credential already known to be bad. + if (authRejected) { + this.authFailed = true + this.emit('auth_error', { code: event.code, reason: event.reason }) + return + } + if (!event.wasClean) { this.scheduleReconnect() } diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index f7ad1ff259..eedd903884 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -4,81 +4,6 @@ */ export interface paths { - "/notifications/admin/recent": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Recent notification rows for a user (all channels + statuses) */ - get: { - parameters: { - query: { - userId: string; - limit: number; - }; - header: { - "x-admin-token": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - items: { - id: string; - eventType: string; - channel: string; - status: "PENDING" | "SENT" | "FAILED" | "SKIPPED"; - skipReason: string | null; - providerId: string | null; - error: unknown; - createdAt: string; - sentAt: string | null; - }[]; - }; - }; - }; - /** @description Default Response */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - /** @description Default Response */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/": { parameters: { query?: never; @@ -359,7 +284,7 @@ export interface paths { content: { "application/json": { accountIdentifier: string; - accountType: "iban" | "us" | "evm-address" | "peanut-wallet" | "bridgeBankAccount" | "manteca" | "simplefi-merchant" | "clabe" | "cbu" | "cvu" | "pix"; + accountType: "iban" | "us" | "evm-address" | "peanut-wallet" | "bridgeBankAccount" | "manteca" | "clabe" | "cbu" | "cvu" | "pix"; userId: string; bridgeAccountIdentifier?: string; chainId?: string; @@ -564,7 +489,7 @@ export interface paths { patch?: never; trace?: never; }; - "/get-user-salt": { + "/update-user": { parameters: { query?: never; header?: never; @@ -576,7 +501,8 @@ export interface paths { post: { parameters: { query?: never; - header?: { + header: { + Authorization: string; "api-key"?: string; }; path?: never; @@ -585,7 +511,18 @@ export interface paths { requestBody: { content: { "application/json": { - email: string; + userId: string; + username?: string; + email?: string; + fullName?: string; + bridge_customer_id?: string; + telegramUsername?: string; + offrampHandle?: string; + pushSubscriptionId?: string; + showFullName?: boolean; + hasSeenEarlyUserModal?: boolean; + bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; + dismissActivationCelebration?: boolean; }; }; }; @@ -605,7 +542,7 @@ export interface paths { patch?: never; trace?: never; }; - "/update-user": { + "/users/me/delete": { parameters: { query?: never; header?: never; @@ -619,29 +556,11 @@ export interface paths { query?: never; header: { Authorization: string; - "api-key"?: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - userId: string; - username?: string; - email?: string; - fullName?: string; - bridge_customer_id?: string; - telegramUsername?: string; - offrampHandle?: string; - pushSubscriptionId?: string; - showFullName?: boolean; - hasSeenEarlyUserModal?: boolean; - bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; - dismissActivationCelebration?: boolean; - }; - }; - }; + requestBody?: never; responses: { /** @description Default Response */ 200: { @@ -658,7 +577,7 @@ export interface paths { patch?: never; trace?: never; }; - "/users/me/delete": { + "/users/logout": { parameters: { query?: never; header?: never; @@ -770,48 +689,6 @@ export interface paths { patch?: never; trace?: never; }; - "/users/track-transaction": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - payerAddress?: string; - txHash?: string; - sourceChainId?: string; - sourceTokenAddress?: string; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/history/{entryId}": { parameters: { query?: never; @@ -849,44 +726,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/users/{userId}/acknowledgments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header: { - Authorization: string; - "api-key"?: string; - }; - path: { - userId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/users/search": { parameters: { query?: never; @@ -1786,6 +1625,123 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/step-up/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + rpID?: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/step-up/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + cred: unknown; + rpID?: string; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + token: string; + expiresIn: number; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/requests": { parameters: { query?: never; @@ -2587,118 +2543,11 @@ export interface paths { }; path: { customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - externalAccountId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/kyc-links": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - type: "business" | "individual"; - fullName: string; - email: string; - redirectUri?: string; - isEEA?: boolean; - }; + externalAccountId: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Default Response */ 200: { @@ -2709,27 +2558,17 @@ export interface paths { }; }; }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{uuid}/kyc-links": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { + put?: never; + post?: never; + delete: { parameters: { query?: never; header?: { "api-key"?: string; }; path: { - uuid: string; + customerId: string; + externalAccountId: string; }; cookie?: never; }; @@ -2744,35 +2583,29 @@ export interface paths { }; }; }; - put?: never; - post?: never; - delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bridge/customers/{uuid}/transfers": { + "/bridge/customers/{customerId}/external-accounts/{externalAccountId}/reactivate": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: { + get?: never; + put?: never; + post: { parameters: { - query?: { - limit?: number; - startingAfter?: string; - endingBefore?: string; - updatedBeforeMs?: number; - updatedAfterMs?: number; - }; + query?: never; header?: { "api-key"?: string; }; path: { - uuid: string; + customerId: string; + externalAccountId: string; }; cookie?: never; }; @@ -2787,15 +2620,13 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bridge/transfers/{transferId}": { + "/bridge/customers/{uuid}/kyc-links": { parameters: { query?: never; header?: never; @@ -2809,7 +2640,7 @@ export interface paths { "api-key"?: string; }; path: { - transferId: string; + uuid: string; }; cookie?: never; }; @@ -3283,97 +3114,6 @@ export interface paths { patch?: never; trace?: never; }; - "/bridge/onramp/create-for-guest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Initiate an on-ramp transfer for a guest - * @description This endpoint initiates a new on-ramp transfer (fiat to crypto) for a guest. It creates a transfer from fiat to USDC on Arbitrum to the guest's peanut wallet and returns bank deposit instructions. - */ - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - amount: string; - source: { - currency: "usd" | "eur" | "mxn" | "gbp"; - paymentRail: "ach" | "ach_push" | "ach_same_day" | "wire" | "sepa" | "swift" | "spei" | "faster_payments"; - }; - userId: string; - chargeId?: string; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - transferId: string; - depositInstructions: { - amount: string; - currency: string; - depositMessage: string; - bankName?: string; - bankAddress?: string; - bankRoutingNumber?: string; - bankAccountNumber?: string; - bankBeneficiaryName?: string; - bankBeneficiaryAddress?: string; - iban?: string; - bic?: string; - accountHolderName?: string; - }; - }; - }; - }; - /** @description Default Response */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - /** @description Default Response */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - error: string; - }; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/bridge/onramp/quote": { parameters: { query?: never; @@ -4838,6 +4578,84 @@ export interface paths { patch?: never; trace?: never; }; + "/notifications/admin/recent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Recent notification rows for a user (all channels + statuses) */ + get: { + parameters: { + query: { + userId: string; + limit: number; + }; + header: { + "x-admin-token": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: { + id: string; + eventType: string; + channel: string; + status: "PENDING" | "SENT" | "FAILED" | "SKIPPED"; + skipReason: string | null; + providerId: string | null; + error: { + reason: string | null; + name: string | null; + } | null; + createdAt: string; + sentAt: string | null; + }[]; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/notifications": { parameters: { query?: never; @@ -4937,6 +4755,60 @@ export interface paths { patch?: never; trace?: never; }; + "/unsubscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query: { + token: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post: { + parameters: { + query: { + token: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/webhooks/onesignal": { parameters: { query?: never; @@ -6385,6 +6257,7 @@ export interface paths { spendingPower: number; inTransitToCollateralCents: number; }; + balanceUnavailable: boolean; cards: { id: string; rainCardId: string; @@ -8966,7 +8839,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO"; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 9bcce42741..0dd8008360 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -8,171 +8,6 @@ "schemas": {} }, "paths": { - "/notifications/admin/recent": { - "get": { - "summary": "Recent notification rows for a user (all channels + statuses)", - "tags": ["admin"], - "parameters": [ - { - "schema": { - "minLength": 1, - "type": "string" - }, - "in": "query", - "name": "userId", - "required": true - }, - { - "schema": { - "minimum": 1, - "maximum": 100, - "default": 20, - "type": "integer" - }, - "in": "query", - "name": "limit", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "x-admin-token", - "required": true - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "eventType": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "enum": ["PENDING"] - }, - { - "type": "string", - "enum": ["SENT"] - }, - { - "type": "string", - "enum": ["FAILED"] - }, - { - "type": "string", - "enum": ["SKIPPED"] - } - ] - }, - "skipReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "error": {}, - "createdAt": { - "type": "string" - }, - "sentAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "eventType", - "channel", - "status", - "skipReason", - "providerId", - "error", - "createdAt", - "sentAt" - ] - } - } - }, - "required": ["items"] - } - } - } - }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, "/": { "get": { "responses": { @@ -371,10 +206,6 @@ "type": "string", "enum": ["manteca"] }, - { - "type": "string", - "enum": ["simplefi-merchant"] - }, { "type": "string", "enum": ["clabe"] @@ -942,41 +773,6 @@ } } }, - "/get-user-salt": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - } - }, - "required": ["email"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/update-user": { "post": { "requestBody": { @@ -1094,6 +890,25 @@ } } }, + "/users/logout": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/users/contacts": { "get": { "parameters": [ @@ -1180,38 +995,6 @@ } } }, - "/users/track-transaction": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "payerAddress": { - "type": "string" - }, - "txHash": { - "type": "string" - }, - "sourceChainId": { - "type": "string" - }, - "sourceTokenAddress": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/history/{entryId}": { "get": { "parameters": [ @@ -1239,42 +1022,7 @@ } } }, - "/api/users/{userId}/acknowledgments": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "userId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "Authorization", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/users/search": { + "/users/search": { "get": { "responses": { "200": { @@ -2313,6 +2061,119 @@ } } }, + "/auth/step-up/options": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rpID": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/auth/step-up/verify": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cred": {}, + "rpID": { + "type": "string" + } + }, + "required": ["cred"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "number" + } + }, + "required": ["token", "expiresIn"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/requests": { "post": { "requestBody": { @@ -2377,8 +2238,8 @@ "type": "string" }, "tokenAddress": { - "type": "string", - "pattern": "^0x[a-fA-F0-9]{40}$" + "pattern": "^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$", + "type": "string" }, "tokenDecimals": { "type": "string" @@ -3551,62 +3412,6 @@ } } }, - "/bridge/kyc-links": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "anyOf": [ - { - "type": "string", - "enum": ["business"] - }, - { - "type": "string", - "enum": ["individual"] - } - ] - }, - "fullName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "redirectUri": { - "type": "string" - }, - "isEEA": { - "type": "boolean" - } - }, - "required": ["type", "fullName", "email"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/customers/{uuid}/kyc-links": { "get": { "parameters": [ @@ -3634,100 +3439,6 @@ } } }, - "/bridge/customers/{uuid}/transfers": { - "get": { - "parameters": [ - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "startingAfter", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "endingBefore", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "updatedBeforeMs", - "required": false - }, - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "updatedAfterMs", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/transfers/{transferId}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "transferId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/offramp/create": { "post": { "summary": "Initiate an off-ramp transfer", @@ -4631,227 +4342,18 @@ } } }, - "400": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "401": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "403": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] - } - } - } - } - } - } - }, - "/bridge/onramp/create-for-guest": { - "post": { - "summary": "Initiate an on-ramp transfer for a guest", - "tags": ["onramp"], - "description": "This endpoint initiates a new on-ramp transfer (fiat to crypto) for a guest. It creates a transfer from fiat to USDC on Arbitrum to the guest's peanut wallet and returns bank deposit instructions.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["usd"] - }, - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["mxn"] - }, - { - "type": "string", - "enum": ["gbp"] - } - ] - }, - "paymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["wire"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["spei"] - }, - { - "type": "string", - "enum": ["faster_payments"] - } - ] - } - }, - "required": ["currency", "paymentRail"] - }, - "userId": { - "type": "string" - }, - "chargeId": { - "type": "string" - } - }, - "required": ["amount", "source", "userId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "transferId": { - "type": "string" - }, - "depositInstructions": { - "type": "object", - "properties": { - "amount": { - "type": "string" - }, - "currency": { - "type": "string" - }, - "depositMessage": { - "type": "string" - }, - "bankName": { - "type": "string" - }, - "bankAddress": { - "type": "string" - }, - "bankRoutingNumber": { - "type": "string" - }, - "bankAccountNumber": { - "type": "string" - }, - "bankBeneficiaryName": { - "type": "string" - }, - "bankBeneficiaryAddress": { - "type": "string" - }, - "iban": { - "type": "string" - }, - "bic": { - "type": "string" - }, - "accountHolderName": { - "type": "string" - } - }, - "required": ["amount", "currency", "depositMessage"] + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" } }, - "required": ["transferId", "depositInstructions"] + "required": ["error"] } } } @@ -4872,6 +4374,22 @@ } } }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "Default Response", "content": { @@ -5879,7 +5397,7 @@ "get": { "summary": "Get deposit order status", "tags": ["manteca"], - "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only \u2014 the webhook/poller remain the authoritative completion trigger.", + "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only — the webhook/poller remain the authoritative completion trigger.", "parameters": [ { "schema": { @@ -6972,6 +6490,203 @@ } } }, + "/notifications/admin/recent": { + "get": { + "summary": "Recent notification rows for a user (all channels + statuses)", + "tags": ["admin"], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true + }, + { + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "integer" + }, + "in": "query", + "name": "limit", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["PENDING"] + }, + { + "type": "string", + "enum": ["SENT"] + }, + { + "type": "string", + "enum": ["FAILED"] + }, + { + "type": "string", + "enum": ["SKIPPED"] + } + ] + }, + "skipReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["reason", "name"] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "sentAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "eventType", + "channel", + "status", + "skipReason", + "providerId", + "error", + "createdAt", + "sentAt" + ] + } + } + }, + "required": ["items"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/notifications": { "get": { "responses": { @@ -6999,6 +6714,44 @@ } } }, + "/unsubscribe": { + "get": { + "tags": ["notifications"], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "post": { + "tags": ["notifications"], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/webhooks/onesignal": { "post": { "responses": { @@ -8280,7 +8033,7 @@ }, "/card/waitlist/state": { "get": { - "summary": "Get the user\u2019s current waitlist state", + "summary": "Get the user’s current waitlist state", "tags": ["card"], "responses": { "200": { @@ -8663,6 +8416,9 @@ } ] }, + "balanceUnavailable": { + "type": "boolean" + }, "cards": { "type": "array", "items": { @@ -8710,7 +8466,7 @@ } } }, - "required": ["status", "balance", "cards"] + "required": ["status", "balance", "balanceUnavailable", "cards"] } } } @@ -10664,7 +10420,7 @@ "post": { "summary": "Run one polling cycle synchronously", "tags": ["dev"], - "description": "Triggers runPollingCycle() on demand \u2014 used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", + "description": "Triggers runPollingCycle() on demand — used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", "responses": { "200": { "description": "Default Response", @@ -10884,7 +10640,7 @@ "post": { "summary": "Run KYC provider submission synchronously for the given user/applicant", "tags": ["dev"], - "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user \u2192 Manteca, US user \u2192 Bridge) end-to-end.", + "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user → Manteca, US user → Bridge) end-to-end.", "requestBody": { "content": { "application/json": { @@ -10952,7 +10708,7 @@ "post": { "summary": "Synthesise a Sumsub webhook and run the status-processor", "tags": ["dev"], - "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) \u2014 same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub \u2192 routing chain without real webhook delivery.", + "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) — same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub → routing chain without real webhook delivery.", "requestBody": { "content": { "application/json": { @@ -12114,6 +11870,10 @@ { "type": "string", "enum": ["FESTA_JUNINA_2026"] + }, + { + "type": "string", + "enum": ["MANICERO"] } ] }, diff --git a/src/utils/__tests__/csp-report.utils.test.ts b/src/utils/__tests__/csp-report.utils.test.ts new file mode 100644 index 0000000000..941f4dc811 --- /dev/null +++ b/src/utils/__tests__/csp-report.utils.test.ts @@ -0,0 +1,228 @@ +import { + cspReportGroupKey, + normalizeCspReports, + sentryCspIngestUrl, + shouldIgnoreCspReport, + type CspReport, +} from '../csp-report.utils' + +describe('shouldIgnoreCspReport', () => { + it.each([ + 'chrome-extension://abcdefghijklmnop/inject.js', + 'moz-extension://1234-5678/content.js', + 'safari-web-extension://ABCD/script.js', + 'resource://gre/modules/Foo.jsm', + 'about:blank', + ])('ignores extension/browser-internal blocked-uri %s', (blockedUri) => { + expect(shouldIgnoreCspReport({ 'blocked-uri': blockedUri })).toBe(true) + }) + + it('ignores a report whose source-file is an extension, even when the blocked-uri looks real', () => { + expect( + shouldIgnoreCspReport({ + 'blocked-uri': 'https://evil-tracker.example/beacon', + 'source-file': 'chrome-extension://abcdefghijklmnop/inject.js', + }) + ).toBe(true) + }) + + it('keeps the wss gap that this hotfix closes', () => { + expect( + shouldIgnoreCspReport({ + 'blocked-uri': 'wss://api.peanut.me', + 'effective-directive': 'connect-src', + }) + ).toBe(false) + }) + + it.each(['inline', 'eval', 'data', 'blob'])( + 'keeps keyword blocked-uri %s — genuine signal about how loose script-src still is', + (blockedUri) => { + expect(shouldIgnoreCspReport({ 'blocked-uri': blockedUri })).toBe(false) + } + ) + + it('keeps a real third-party host we simply forgot to allow-list', () => { + expect(shouldIgnoreCspReport({ 'blocked-uri': 'https://arb1.arbitrum.io/rpc' })).toBe(false) + }) + + it('does not treat a missing blocked-uri as noise', () => { + expect(shouldIgnoreCspReport({})).toBe(false) + }) +}) + +describe('normalizeCspReports', () => { + it('reads the legacy report-uri payload', () => { + expect( + normalizeCspReports({ + 'csp-report': { 'blocked-uri': 'wss://api.peanut.me', 'effective-directive': 'connect-src' }, + }) + ).toEqual([{ 'blocked-uri': 'wss://api.peanut.me', 'effective-directive': 'connect-src' }]) + }) + + it('reads a Reporting-API batch and maps it onto the legacy shape', () => { + const [report] = normalizeCspReports([ + { + type: 'csp-violation', + body: { + blockedURL: 'wss://api.peanut.me', + documentURL: 'https://peanut.me/card', + effectiveDirective: 'connect-src', + disposition: 'report', + }, + }, + ]) + + expect(report['blocked-uri']).toBe('wss://api.peanut.me') + expect(report['document-uri']).toBe('https://peanut.me/card') + expect(report['effective-directive']).toBe('connect-src') + expect(report['violated-directive']).toBe('connect-src') + }) + + it('drops non-CSP entries from a Reporting-API batch', () => { + expect( + normalizeCspReports([ + { type: 'deprecation', body: { id: 'SomeApi' } }, + { type: 'csp-violation', body: { blockedURL: 'inline', effectiveDirective: 'script-src' } }, + ]) + ).toHaveLength(1) + }) + + it.each([null, undefined, 'not json', 42, {}, { 'csp-report': 'nonsense' }])( + 'returns nothing for the malformed payload %p rather than throwing', + (payload) => { + expect(normalizeCspReports(payload)).toEqual([]) + } + ) +}) + +describe('cspReportGroupKey', () => { + it('collapses different paths on one origin into a single group', () => { + const a: CspReport = { 'effective-directive': 'connect-src', 'blocked-uri': 'https://arb1.arbitrum.io/rpc' } + const b: CspReport = { 'effective-directive': 'connect-src', 'blocked-uri': 'https://arb1.arbitrum.io/other' } + + expect(cspReportGroupKey(a)).toBe(cspReportGroupKey(b)) + }) + + it('separates different directives on the same origin', () => { + expect( + cspReportGroupKey({ 'effective-directive': 'connect-src', 'blocked-uri': 'https://a.example' }) + ).not.toBe(cspReportGroupKey({ 'effective-directive': 'script-src', 'blocked-uri': 'https://a.example' })) + }) + + it('handles keyword blocked-uris that are not URLs', () => { + expect(cspReportGroupKey({ 'effective-directive': 'script-src', 'blocked-uri': 'inline' })).toBe( + 'script-src|inline' + ) + }) + + // Sentry's csp:v1 strategy keys these two on the keyword rather than the + // blocked URI, so it raises two separate issues. If the group key collapsed + // them, the second would be sampled away as a duplicate and its issue might + // never be created — in the one category this policy most needs to see. + it('separates unsafe-inline from unsafe-eval, matching how Sentry groups them', () => { + const inline = cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-inline'", + 'blocked-uri': 'self', + }) + const evaluated = cspReportGroupKey({ + 'effective-directive': 'script-src', + 'violated-directive': "script-src 'unsafe-eval'", + 'blocked-uri': 'self', + }) + + expect(inline).toBe('script-src|unsafe-inline') + expect(evaluated).toBe('script-src|unsafe-eval') + expect(inline).not.toBe(evaluated) + }) + + // What browsers actually emit: the specific sub-directive that was checked. + // Inline