Skip to content

fix(rebate-indexer): CORS preflight missing Allow-Methods/Allow-Headers broke every signed POST#567

Merged
san-npm merged 1 commit into
mainfrom
fix/rebate-indexer-cors-preflight
Jun 12, 2026
Merged

fix(rebate-indexer): CORS preflight missing Allow-Methods/Allow-Headers broke every signed POST#567
san-npm merged 1 commit into
mainfrom
fix/rebate-indexer-cors-preflight

Conversation

@san-npm

@san-npm san-npm commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Root cause of the Partner dashboard failure

The Partner dashboard (swap.ophis.fi/#/partner) showed "Could not load the dashboard / Something went wrong". A multi-agent audit traced it to the rebate-indexer CORS preflight:

  • The onRequest hook set only Access-Control-Allow-Origin (+ Vary); app.options('*') returned a bare 204.
  • The affiliate/partner pages issue signed POSTs (/partner, /ref/bind, /ref/codes) with content-type: application/json — a non-safelisted header that forces a CORS preflight.
  • With no Access-Control-Allow-Methods and no Access-Control-Allow-Headers on the preflight, the browser blocks the real POST; fetch() rejects with a TypeError, which the page can't classify → generic error.
  • Simple GETs (/affiliate, /tier, /leaderboard) need no preflight → kept working. That asymmetry (reads OK, every signed POST dead) is exactly what was observed. curl isn't bound by CORS, so direct POSTs reached the real 401/403.

This silently broke the entire signed referral flow (partner dashboard and referral code bind/mint), not just the dashboard.

Fix

For allowed origins, the preflight now also sets Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: content-type, accept, Access-Control-Max-Age: 600, and a correct Vary. 4 regression tests added (preflight on /partner + /ref/bind + /ref/codes echoes the headers; disallowed origin gets none) — the hand-rolled CORS path had zero coverage.

Audit notes (separate)

  • Data integrity: healthy. Partner san = kind:'partner', 12% rate, activeCodes:['san']; referredCount:0 / currentCycleVolumeUsd:0 are correct (no wallets bound yet), not stale. Rates (8%/12%) verified correct. No data/money bugs.
  • Frontend error-masking (parseJson discards the server {error}; the page collapses TypeError/4xx/5xx into the generic message; the defined AFFILIATE_API_TIMEOUT_MS is never wired in) is a follow-up PR — not required to fix the outage, but makes the next failure diagnosable.

Verification

  • tsc --noEmit green; vitest 16/16 pass.
  • Post-deploy: curl -i -X OPTIONS https://rebates.ophis.fi/partner -H 'Origin: https://swap.ophis.fi' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: content-type' must show access-control-allow-methods (incl POST) + access-control-allow-headers (incl content-type).

🤖 Generated with Claude Code

…rs broke every signed POST

ROOT CAUSE of the partner dashboard "Could not load the dashboard" error (and a
silent break of the whole signed referral flow): the CORS onRequest hook set
ONLY Access-Control-Allow-Origin, and app.options('*') replied a bare 204. The
affiliate/partner pages POST /partner, /ref/bind, /ref/codes with
`content-type: application/json` (a non-safelisted header), which forces a
browser CORS preflight. With no Access-Control-Allow-Methods and no
Access-Control-Allow-Headers on the preflight response, the browser blocks the
real POST and fetch() rejects with a TypeError -> the page falls through to its
generic error state. Simple GETs (/affiliate, /tier, /leaderboard) need no
preflight, so the public read path kept working: exactly the observed asymmetry.
curl is not bound by CORS, which is why direct POSTs reached the real 401/403.

Fix: for allowed origins, also set Access-Control-Allow-Methods (GET, POST,
OPTIONS), Access-Control-Allow-Headers (content-type, accept), Access-Control-
Max-Age, and a proper Vary. Adds 4 regression tests (allowed-origin preflight on
/partner + /ref/bind + /ref/codes echoes the headers; disallowed origin gets none).

Found via a multi-agent audit. Data integrity verified healthy (partner 'san'
= 12% rate, referredCount:0 is correct, not stale). Frontend error-masking
(parseJson drops the server message; generic catch-all) is a separate follow-up.

typecheck green; 16/16 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@san-npm san-npm merged commit b60c6ee into main Jun 12, 2026
18 checks passed
@san-npm san-npm deleted the fix/rebate-indexer-cors-preflight branch June 12, 2026 11:11
san-npm added a commit that referenced this pull request Jun 12, 2026
…I timeout (audit follow-up) (#568)

The CORS-preflight bug (fixed server-side in #567) surfaced as an opaque "Could
not load the dashboard / Something went wrong" because the frontend collapsed
every non-403/401 failure into one generic state and discarded the server's
error message. These fixes make the next failure diagnosable (no behavior change
to the happy path):

- ophisAffiliateApi.parseJson: read the server's `{error}` body into
  AffiliateApiError.message (was thrown away, leaving a bare status).
- ophisAffiliateApi: wire the defined-but-unused AFFILIATE_API_TIMEOUT_MS into
  the signed POSTs (/partner, /ref/bind, /ref/codes) via AbortSignal.timeout, so
  a hung preflight/backend fails fast instead of spinning forever.
- Partner.container: distinguish a network/CORS/timeout failure (TypeError /
  AbortError) into a dedicated "Could not reach the partner service" state, log
  the status+message for real server errors, and console.error transport failures.
- OphisAffiliateDashboard: render the rate from stats.rateOfNetFeePct (8 only as
  a pre-load fallback) instead of a hardcoded "8%", so the view tracks the
  backend FEE_SHARE_BPS.
- RefCodeCaptureUpdater: console.debug -> console.warn for transient bind
  failures so a persistent backend/transport break is visible in logs.

Data integrity verified healthy in the audit (partner rates 8%/12% correct;
referredCount:0 correct). typecheck green; 949/951 tests pass (2 pre-existing
unrelated OP-router snapshots).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant