Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)
This commit is contained in:
+137
-1
@@ -4,7 +4,143 @@
|
||||
2026-06-10
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v9.0 — Critical site fixes, data-source upgrade, production readiness (Session 9)
|
||||
SHIP BUILD v10.0 — Internal-auth refactor, soccer prefetch cascade, Sentry, welcome email (Session 10)
|
||||
|
||||
## Session 10 (2026-06-10) — SHIPPED
|
||||
|
||||
### FIX 1 — Internal auth refactor + /pipeline off-host support
|
||||
|
||||
Pre-audit revealed the spec's premise was wrong: `/api/grading/pipeline`
|
||||
and `/api/grading/resolve` ALREADY EXISTED with `requireInternal`
|
||||
middleware inline in each route file. The actual n8n bug was a header-
|
||||
name mismatch (n8n sends `x-internal-key`, code read
|
||||
`X-VYNDR-Internal-Key`) PLUS a hard loopback-IP check that blocks any
|
||||
caller from a separate container.
|
||||
|
||||
- **`src/middleware/internalAuth.js`** (new) — centralized middleware.
|
||||
Accepts BOTH `x-internal-key` (Session 10 short form, n8n) AND
|
||||
`X-VYNDR-Internal-Key` (legacy, poller + existing tests). Timing-safe
|
||||
string compare. `loopbackOnly` is now an OPT-IN flag (default off).
|
||||
- **`src/routes/grading.js`** — replaced inline `requireInternal` with
|
||||
the centralized middleware. `/resolve` uses `{loopbackOnly: true}`
|
||||
(poller from localhost). `/pipeline` uses the off-host variant
|
||||
(n8n from a separate container). `__helpers.requireInternal` kept
|
||||
exported for the existing test suite — backwards compatible.
|
||||
- **`src/routes/corrections.js`** — same refactor; `/correct` stays
|
||||
loopback-only (morning sweep is co-located).
|
||||
- **`/api/grading/pipeline`** body shape — empty body now iterates
|
||||
`nba/wnba/mlb` (n8n's "Morning Ops" workflow case). Single-sport
|
||||
body still works and returns the legacy summary object so existing
|
||||
per-sport tests continue to pass.
|
||||
|
||||
### FIX 2 — Soccer prefetch cascade keys
|
||||
|
||||
Session 9's adapters write to `apifootball:*` and `footapi:*` cache
|
||||
keys; the daily prefetch was still only writing `soccer:*` (the
|
||||
tertiary fallback). The cascade in `soccerFeatureExtractor` never
|
||||
hit PRIMARY because nothing populated those keys.
|
||||
|
||||
- **`scripts/soccer-data-prefetch.js`** — new `enrichFromApiFootball()`
|
||||
walks finished WC fixtures via `apiFootballAdapter.getFixtures` +
|
||||
`getFixturePlayerStats`, aggregates per-player season stats across
|
||||
matches (minutes, goals, assists, shots, tackles, cards, rating),
|
||||
collapses to per-90 rates, and writes
|
||||
`apifootball:player_by_name:{normalizedName}` (24h TTL). Hard-capped
|
||||
at `--max-players=80` per run.
|
||||
- **CLI flags added** — `--source=api-football|footapi|football-data|all`
|
||||
(default `all`), `--max-players=N`, `--season=N`. Existing `--leagues`
|
||||
and `--dry-run` flags unchanged.
|
||||
- **`enrichRefereesFromFootApi()`** — best-effort referee enrichment.
|
||||
Writes `footapi:referee_by_name:{name}` (7d TTL).
|
||||
- **Behavior preserved** — legacy `soccer:player:*` writes still happen
|
||||
when `football-data` source is selected (and it's the default in
|
||||
`all` mode). The cascade resolves at PRIMARY when api-football data
|
||||
is available, TERTIARY otherwise.
|
||||
- **Boot guard relaxed** — previously bailed when
|
||||
`FOOTBALL_DATA_API_KEY` was unset; now bails only when EVERY source
|
||||
is unavailable. The script can run on api-football alone.
|
||||
|
||||
### FIX 3 — Sentry error tracking
|
||||
|
||||
- **`src/utils/sentry.js`** (new) — graceful no-op when `SENTRY_DSN`
|
||||
is unset (every Sentry surface becomes a noop). Initialized at the
|
||||
top of `src/app.js` BEFORE express is required.
|
||||
- **`Sentry.setupExpressErrorHandler(app)`** mounted AFTER all routes
|
||||
in `app.js` — catches uncaught route errors automatically.
|
||||
- **PII scrubbing** — `beforeSend` strips `user.ip_address`,
|
||||
`user.email`, `request.cookies`, `request.headers.authorization`,
|
||||
`request.headers.cookie`, and BOTH internal-key headers. Bearer
|
||||
tokens never reach Sentry.
|
||||
- **Sampling** — 10% traces, 100% errors. Free-tier friendly.
|
||||
- **Frontend** — manual init via `web/src/components/SentryInit.tsx`
|
||||
(client component, mounted in root layout). Lazy `import('@sentry/nextjs')`
|
||||
fires on mount only if `NEXT_PUBLIC_SENTRY_DSN` is set. Avoids the
|
||||
`withSentryConfig` plugin which conflicts with standalone output
|
||||
mode (per Session 10 spec note).
|
||||
|
||||
### FIX 4 — Welcome email on signup
|
||||
|
||||
The `sendWelcomeEmail` function in `web/src/services/email.ts` already
|
||||
existed; nobody called it.
|
||||
|
||||
- **Copy updated** — 5/month → 3/day, NexaPay → Stripe founder pricing
|
||||
($14.99/mo locked for life), added the soccer/World Cup mention per
|
||||
Session 10 spec. Both HTML and plain-text variants.
|
||||
- **`web/src/app/api/welcome-email/route.ts`** (new) — POST endpoint,
|
||||
bearer-auth required. Reads Supabase `user_metadata` via the
|
||||
service-role admin client, checks `welcome_email_sent`, sends if
|
||||
absent, sets the flag. Idempotent — re-trigger is a cheap noop.
|
||||
**No migration needed** — `user_metadata` is the Supabase auth
|
||||
user's existing JSONB scratchpad.
|
||||
- **Trigger** — `web/src/app/welcome/page.tsx` fires the POST once on
|
||||
mount via `useRef` guard. Server-side idempotency keeps it safe
|
||||
across refreshes too.
|
||||
- **Graceful failure** — if `RESEND_API_KEY` is unset, send returns
|
||||
`{ ok: false }` but the flag is still set (manual operator override
|
||||
if a batch needs re-sending).
|
||||
|
||||
### Tests added
|
||||
|
||||
| Suite | Tests |
|
||||
|--------------------------------------------------------|-------|
|
||||
| `tests/unit/internalAuth.test.js` | 15 |
|
||||
| `tests/unit/soccerDataPrefetchCascade.test.js` | 20 |
|
||||
| `tests/unit/sentry.test.js` | 10 |
|
||||
| Existing suites (pipeline, resolution, prefetch) re-verified | 0 new |
|
||||
| **Session 10 total** | **45+** |
|
||||
|
||||
### Quality gates
|
||||
- `npm test`: **1286 / 1286 passing** (1240 + 46 new), 100 suites, 0 regressions
|
||||
- `web/npm run build`: clean — Sentry mount + `/api/welcome-email` prerender
|
||||
- License audit: only permissive licenses (Sentry adds nothing exotic)
|
||||
|
||||
### Env vars to set in Coolify
|
||||
|
||||
```
|
||||
# Already required from prior sessions:
|
||||
VYNDR_INTERNAL_KEY=<existing — header is now x-internal-key OR X-VYNDR-Internal-Key>
|
||||
RESEND_API_KEY=<existing>
|
||||
RESEND_FROM_EMAIL=<existing, defaults to "VYNDR <grades@vyndr.app>">
|
||||
|
||||
# New in Session 10 (all optional — wrappers degrade gracefully):
|
||||
SENTRY_DSN=<from sentry.io project settings>
|
||||
NEXT_PUBLIC_SENTRY_DSN=<same DSN — needs the NEXT_PUBLIC_ prefix to reach browser bundle>
|
||||
```
|
||||
|
||||
### Open items
|
||||
- Soccer prefetch hasn't run against live api-football yet — first
|
||||
cron tick after deploy will populate the cascade. Until then, the
|
||||
feature extractor resolves at tertiary (football-data).
|
||||
- Sentry's frontend manual-init pattern means errors before the React
|
||||
tree mounts (e.g. SSR errors) bypass Sentry. The backend handler
|
||||
catches Express-side errors; for browser-side SSR errors we'd need
|
||||
`instrumentation.ts`, deferred.
|
||||
- Welcome email idempotency relies on Supabase `user_metadata`. If a
|
||||
user signs in via SSO and never lands on `/welcome`, they don't get
|
||||
the email. Acceptable Day-1 — track via PostHog if it becomes a
|
||||
real conversion gap.
|
||||
|
||||
---
|
||||
|
||||
## Session 9 (2026-06-10) — SHIPPED
|
||||
|
||||
|
||||
@@ -467,3 +467,22 @@
|
||||
{"ts":"2026-06-10T21:37:51.188Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T21:37:51.300Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T21:37:51.313Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T23:55:25.851Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T23:55:25.926Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T23:55:25.927Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T23:55:25.927Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T23:55:26.364Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T23:55:26.531Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T23:55:27.246Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T00:10:09.376Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-11T00:10:09.377Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T00:10:09.377Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T00:10:09.411Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-11T00:10:09.450Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T00:23:12.094Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T00:23:12.208Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-11T00:23:12.718Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-11T00:23:12.718Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T00:23:12.718Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T00:23:12.772Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-11T00:23:12.832Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
|
||||
@@ -89,7 +89,7 @@ Mounted in `src/app.js`. Auth column meanings:
|
||||
| POST | /api/push/subscribe | user | 10mb | `routes/push.js` |
|
||||
| DELETE | /api/push/unsubscribe | user | 10mb | `routes/push.js` |
|
||||
| POST | /api/grading/resolve | internal | **10mb** | `routes/grading.js` |
|
||||
| POST | /api/grading/pipeline | internal | 10mb | `routes/grading.js` |
|
||||
| POST | /api/grading/pipeline | internal | 10mb | `routes/grading.js` (Session 10: off-host n8n callers; empty body iterates active sports) |
|
||||
| POST | /api/grading/correct | internal | 256kb | `routes/corrections.js` |
|
||||
| GET | /api/widget | public | 10mb | `routes/widget.js` |
|
||||
| OPTIONS| /api/widget | public | n/a | `routes/widget.js` |
|
||||
@@ -135,7 +135,9 @@ back). Updated this session in Section 1 of Session 7c.
|
||||
| `PORT` | no | `3001` | `src/server.js` | implicit |
|
||||
| `BASE_URL` | yes | `http://localhost:3001` | `stripeService`, `widget` | ✓ |
|
||||
| `FRONTEND_ORIGINS` | yes | (none) | `app.js` (CORS) | ✓ |
|
||||
| `VYNDR_INTERNAL_KEY` | yes | (none) | `routes/grading`, `corrections` | ✓ |
|
||||
| `VYNDR_INTERNAL_KEY` | yes | (none) | `middleware/internalAuth` (S10), `routes/grading`, `routes/corrections` | ✓ |
|
||||
| `SENTRY_DSN` | no | (none) | `utils/sentry` (S10) — graceful skip when unset | ✓ S10 |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | no | (none) | `web/components/SentryInit` (S10) — same DSN, browser-side | ✓ S10 |
|
||||
|
||||
### Supabase
|
||||
| Var | Required | Used By | Doc? |
|
||||
|
||||
Generated
+253
-1
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sentry/node": "^10.57.0",
|
||||
"@supabase/supabase-js": "^2.99.3",
|
||||
"axios": "^1.13.6",
|
||||
"cheerio": "^1.2.0",
|
||||
@@ -1488,6 +1489,101 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.214.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz",
|
||||
"integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
|
||||
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation": {
|
||||
"version": "0.214.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz",
|
||||
"integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.214.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"require-in-the-middle": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
|
||||
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
|
||||
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
|
||||
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -1522,6 +1618,108 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/server-utils": {
|
||||
"version": "10.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/server-utils/-/server-utils-10.57.0.tgz",
|
||||
"integrity": "sha512-Qu8ETmX/ITzteG7Im46b9HOxKKzeaIeqNvftaIlFURu1RUQdHbtGerS7QOmXzwnhuqNGNeiCQYkduB798IfRqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.57.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.57.0.tgz",
|
||||
"integrity": "sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node": {
|
||||
"version": "10.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.57.0.tgz",
|
||||
"integrity": "sha512-7KEStrJ97wPf1fA5nU5ONeTTcIIlh7oT8OMffEVA1PXmlhFoXhcQZVzr4rM+zj9tfMWT01og5Ng/Grgh3dN+FA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/core": "^2.6.1",
|
||||
"@opentelemetry/instrumentation": "^0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry-internal/server-utils": "10.57.0",
|
||||
"@sentry/core": "10.57.0",
|
||||
"@sentry/node-core": "10.57.0",
|
||||
"@sentry/opentelemetry": "10.57.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node-core": {
|
||||
"version": "10.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.57.0.tgz",
|
||||
"integrity": "sha512-2v2IF6MfTiu7pimWEq2rYhZsmlwyNbs3bHUsrYFPeP/Rpa6ObDuUWPdVEzJjfyK+AqqYZYxZdV0l3+B13kTEmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.57.0",
|
||||
"@sentry/opentelemetry": "10.57.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
|
||||
"@opentelemetry/instrumentation": ">=0.57.1 <1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-http": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/instrumentation": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/sdk-trace-base": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/semantic-conventions": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/opentelemetry": {
|
||||
"version": "10.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.57.0.tgz",
|
||||
"integrity": "sha512-iwRz8cEK0GOISG34aJRO8GdYOk3nfpuT6dT2GDQrxw8f7JjkJKx9LPU8MaenOFa4MhY+Z02hI6NNcrbsoI3cXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.57.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.48",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz",
|
||||
@@ -2049,6 +2247,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-import-attributes": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
||||
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -2566,7 +2785,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
@@ -3799,6 +4017,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz",
|
||||
"integrity": "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-attributes": "^1.9.5",
|
||||
"cjs-module-lexer": "^2.2.0",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||
@@ -4927,6 +5160,12 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5472,6 +5711,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-in-the-middle": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-cwd": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/kev3109/vyndr#readme",
|
||||
"dependencies": {
|
||||
"@sentry/node": "^10.57.0",
|
||||
"@supabase/supabase-js": "^2.99.3",
|
||||
"axios": "^1.13.6",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
+223
-10
@@ -31,6 +31,8 @@
|
||||
*/
|
||||
|
||||
const fbd = require('../src/services/adapters/footballDataAdapter');
|
||||
const apif = require('../src/services/adapters/apiFootballAdapter');
|
||||
const footapi = require('../src/services/adapters/footApiAdapter');
|
||||
const { cacheSet } = require('../src/utils/redis');
|
||||
const { normalizeName } = require('../src/utils/normalize');
|
||||
|
||||
@@ -38,17 +40,50 @@ const PLAYER_TTL_SEC = 24 * 3600;
|
||||
const STANDINGS_TTL_SEC = 12 * 3600;
|
||||
const SCORERS_TTL_SEC = 6 * 3600;
|
||||
const DEFENSE_TTL_SEC = 12 * 3600;
|
||||
const REFEREE_TTL_SEC = 7 * 24 * 3600;
|
||||
|
||||
// Session 10 — Map football-data competition codes to api-football
|
||||
// league IDs so the prefetch can ask api-football for the matching
|
||||
// season's data. Add codes here as more leagues come online.
|
||||
const APIFOOTBALL_LEAGUE_MAP = Object.freeze({
|
||||
WC: 1, // FIFA World Cup
|
||||
PL: 39, // English Premier League
|
||||
PD: 140, // La Liga
|
||||
BL1: 78, // Bundesliga
|
||||
SA: 135, // Serie A
|
||||
FL1: 61, // Ligue 1
|
||||
CL: 2, // UEFA Champions League
|
||||
MLS: 253, // MLS
|
||||
});
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { leagues: ['WC'], dryRun: false };
|
||||
// Sources controls which adapters get called. `all` (default) tries
|
||||
// every configured adapter; the explicit single-source values are
|
||||
// useful for debugging or for skipping a misbehaving source.
|
||||
const VALID_SOURCES = new Set(['all', 'api-football', 'footapi', 'football-data']);
|
||||
const args = {
|
||||
leagues: ['WC'],
|
||||
dryRun: false,
|
||||
source: 'all',
|
||||
maxPlayers: 80,
|
||||
season: 2026,
|
||||
};
|
||||
for (const a of argv.slice(2)) {
|
||||
if (a.startsWith('--leagues=')) {
|
||||
args.leagues = a.slice('--leagues='.length).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
} else if (a === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else if (a.startsWith('--source=')) {
|
||||
const src = a.slice('--source='.length).trim().toLowerCase();
|
||||
args.source = VALID_SOURCES.has(src) ? src : 'all';
|
||||
} else if (a.startsWith('--max-players=')) {
|
||||
const n = Number(a.slice('--max-players='.length));
|
||||
if (Number.isFinite(n) && n > 0) args.maxPlayers = Math.floor(n);
|
||||
} else if (a.startsWith('--season=')) {
|
||||
const n = Number(a.slice('--season='.length));
|
||||
if (Number.isFinite(n) && n > 1900) args.season = n;
|
||||
}
|
||||
}
|
||||
// env override falls through if no CLI value was given.
|
||||
if (!process.argv.some((a) => a.startsWith('--leagues='))) {
|
||||
const env = process.env.SOCCER_LEAGUES;
|
||||
if (env) args.leagues = env.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
@@ -56,6 +91,13 @@ function parseArgs(argv) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function shouldRunSource(args, source) {
|
||||
// Default to 'all' so callers (and existing tests) that don't set
|
||||
// `source` explicitly get the legacy "run every source" behavior.
|
||||
const requested = args && args.source ? args.source : 'all';
|
||||
return requested === 'all' || requested === source;
|
||||
}
|
||||
|
||||
// Project a single team's standings row into the defensive aggregate
|
||||
// the feature extractor reads. defensive_rank_norm is on a 0..1 scale
|
||||
// (0 = best defense, 1 = worst) so it slots into engine1's opp_rank_stat.
|
||||
@@ -141,8 +183,157 @@ function aggregatePlayerFromScorer(scorerRow) {
|
||||
};
|
||||
}
|
||||
|
||||
async function processLeague(league, { dryRun }) {
|
||||
const summary = { league, standings: 0, scorers: 0, players: 0, teamDefense: 0, skipped: false };
|
||||
// Session 10 — pull finished WC fixtures from api-football and
|
||||
// aggregate per-player season stats across them. Writes
|
||||
// `apifootball:player_by_name:{normalizedName}` so the cascade hits
|
||||
// PRIMARY for these players instead of falling through to
|
||||
// football-data. Hard-capped at `maxPlayers` writes per run.
|
||||
async function enrichFromApiFootball(league, args) {
|
||||
if (!apif.hasApiKey()) {
|
||||
return { skipped: 'no_key', players: 0 };
|
||||
}
|
||||
const leagueId = APIFOOTBALL_LEAGUE_MAP[league];
|
||||
if (!leagueId) {
|
||||
return { skipped: 'unmapped_league', players: 0 };
|
||||
}
|
||||
const fixtures = await apif.getFixtures({ league: leagueId, season: args.season });
|
||||
if (!Array.isArray(fixtures) || fixtures.length === 0) {
|
||||
return { skipped: 'no_fixtures', players: 0 };
|
||||
}
|
||||
|
||||
// Only walk FINISHED fixtures — in-progress games have partial stats
|
||||
// that would skew the per-90 rates. api-football's `status` short
|
||||
// code is 'FT' / 'AET' / 'PEN' for finished, 'NS' / 'TBD' for not
|
||||
// started, '1H' / '2H' / 'HT' / 'ET' / 'BT' / 'P' / 'SUSP' for live.
|
||||
const finishedStatuses = new Set(['FT', 'AET', 'PEN', 'AWD', 'WO']);
|
||||
const finished = fixtures.filter((f) => finishedStatuses.has(f.status));
|
||||
|
||||
// Index by player name across all finished fixtures. We accumulate
|
||||
// raw stats then collapse into per-90 rates at the end.
|
||||
const byPlayer = new Map();
|
||||
let fixtureBudget = Math.min(finished.length, 16); // budget cap — each fixture is 1 api-football call
|
||||
|
||||
for (const fixture of finished.slice(0, fixtureBudget)) {
|
||||
if (byPlayer.size >= args.maxPlayers * 2) break; // header
|
||||
const playerStats = await apif.getFixturePlayerStats(fixture.id);
|
||||
if (!Array.isArray(playerStats)) continue;
|
||||
for (const row of playerStats) {
|
||||
if (!row.name) continue;
|
||||
const key = normalizeName(row.name);
|
||||
const agg = byPlayer.get(key) || {
|
||||
name: row.name,
|
||||
team: row.team,
|
||||
playerId: row.playerId,
|
||||
position: row.position,
|
||||
appearances: 0,
|
||||
starts: 0,
|
||||
minutes: 0,
|
||||
goals: 0,
|
||||
assists: 0,
|
||||
shots_total: 0,
|
||||
shots_on: 0,
|
||||
tackles_total: 0,
|
||||
yellow: 0,
|
||||
red: 0,
|
||||
rating_sum: 0,
|
||||
rating_count: 0,
|
||||
};
|
||||
agg.appearances += 1;
|
||||
if (!row.substitute) agg.starts += 1;
|
||||
agg.minutes += Number(row.minutes) || 0;
|
||||
agg.goals += Number(row.goals) || 0;
|
||||
agg.assists += Number(row.assists) || 0;
|
||||
agg.shots_total += Number(row.shots_total) || 0;
|
||||
agg.shots_on += Number(row.shots_on) || 0;
|
||||
agg.tackles_total += Number(row.tackles_total) || 0;
|
||||
agg.yellow += Number(row.yellow) || 0;
|
||||
agg.red += Number(row.red) || 0;
|
||||
const rating = Number(row.rating);
|
||||
if (Number.isFinite(rating) && rating > 0) {
|
||||
agg.rating_sum += rating;
|
||||
agg.rating_count += 1;
|
||||
}
|
||||
byPlayer.set(key, agg);
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse and persist (within maxPlayers budget).
|
||||
let written = 0;
|
||||
for (const [normalized, agg] of byPlayer) {
|
||||
if (written >= args.maxPlayers) break;
|
||||
const profile = {
|
||||
name: agg.name,
|
||||
team: agg.team,
|
||||
playerId: agg.playerId,
|
||||
position: agg.position,
|
||||
appearances: agg.appearances,
|
||||
starts: agg.starts,
|
||||
minutes: agg.minutes,
|
||||
goals: agg.goals,
|
||||
assists: agg.assists,
|
||||
// Cascade-canonical fields.
|
||||
goals_per_90: agg.minutes > 0 ? Math.round((agg.goals / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
assists_per_90: agg.minutes > 0 ? Math.round((agg.assists / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
minutes_per_game: agg.appearances > 0 ? Math.round(agg.minutes / agg.appearances) : null,
|
||||
start_rate: agg.appearances > 0 ? Math.round((agg.starts / agg.appearances) * 100) / 100 : null,
|
||||
// Soccer-specific overlays.
|
||||
shots_per_90: agg.minutes > 0 ? Math.round((agg.shots_total / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
shots_on_per_90: agg.minutes > 0 ? Math.round((agg.shots_on / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
tackles_per_90: agg.minutes > 0 ? Math.round((agg.tackles_total / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
yellow_per_90: agg.minutes > 0 ? Math.round((agg.yellow / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
avg_rating: agg.rating_count > 0 ? Math.round((agg.rating_sum / agg.rating_count) * 100) / 100 : null,
|
||||
// xG fields still null (see comment at top of file) — when an
|
||||
// api-football endpoint that exposes xG goes live, fill here.
|
||||
xg_per_90: null,
|
||||
xa_per_90: null,
|
||||
xg_delta: null,
|
||||
// Aliases for the legacy reader.
|
||||
recent_form_per_90: null,
|
||||
season_per_90: agg.minutes > 0 ? Math.round((agg.goals / (agg.minutes / 90)) * 1000) / 1000 : null,
|
||||
};
|
||||
if (!args.dryRun) {
|
||||
await cacheSet(`apifootball:player_by_name:${normalized}`, profile, PLAYER_TTL_SEC);
|
||||
}
|
||||
written += 1;
|
||||
}
|
||||
return { players: written, fixturesProcessed: fixtureBudget };
|
||||
}
|
||||
|
||||
// Session 10 — enrich the per-referee cache via FootApi. Referees
|
||||
// move slowly so a 7-day TTL is fine. This pass is best-effort: if
|
||||
// no key, skip; if a specific referee 404s, log + continue.
|
||||
async function enrichRefereesFromFootApi(refereeIds, args) {
|
||||
if (!footapi.hasApiKey()) return { skipped: 'no_key', referees: 0 };
|
||||
if (!Array.isArray(refereeIds) || refereeIds.length === 0) return { referees: 0 };
|
||||
let written = 0;
|
||||
for (const { id, name } of refereeIds) {
|
||||
if (!id || !name) continue;
|
||||
const stats = await footapi.getRefereeStatistics(id);
|
||||
if (!Array.isArray(stats) || stats.length === 0) continue;
|
||||
// Find the WC-2026 row if present, else collapse across tournaments.
|
||||
const wc = stats.find((s) => s.tournamentId === 16) || stats[0];
|
||||
const payload = {
|
||||
name,
|
||||
cards_per_game: wc.yellowCardsPerGame,
|
||||
penalties_per_game: null, // FootApi schema doesn't expose this directly
|
||||
appearances: wc.appearances,
|
||||
yellow_cards: wc.yellowCards,
|
||||
red_cards: wc.redCards,
|
||||
};
|
||||
if (!args.dryRun) {
|
||||
await cacheSet(`footapi:referee_by_name:${name}`, payload, REFEREE_TTL_SEC);
|
||||
}
|
||||
written += 1;
|
||||
}
|
||||
return { referees: written };
|
||||
}
|
||||
|
||||
async function processLeague(league, args) {
|
||||
const { dryRun } = args;
|
||||
const summary = {
|
||||
league, standings: 0, scorers: 0, players: 0, teamDefense: 0,
|
||||
apiFootballPlayers: 0, apiFootballSkipped: null, skipped: false,
|
||||
};
|
||||
|
||||
const [standings, scorers] = await Promise.all([
|
||||
fbd.getLeagueStandings(league),
|
||||
@@ -180,8 +371,12 @@ async function processLeague(league, { dryRun }) {
|
||||
if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:standings`, standings, STANDINGS_TTL_SEC);
|
||||
}
|
||||
|
||||
// ---- Scorers → per-player aggregates ----
|
||||
if (Array.isArray(scorers)) {
|
||||
// ---- Scorers → per-player aggregates (football-data, TERTIARY) ----
|
||||
// Always write the legacy soccer:player:* keys so the cascade has a
|
||||
// working fallback even when api-football is rate-limited or
|
||||
// misconfigured. These rows are thinner (no per-match minutes, no
|
||||
// rating) but they keep the engine producing non-null features.
|
||||
if (Array.isArray(scorers) && shouldRunSource(args, 'football-data')) {
|
||||
summary.scorers = scorers.length;
|
||||
for (const s of scorers) {
|
||||
if (!s?.name) continue;
|
||||
@@ -193,6 +388,13 @@ async function processLeague(league, { dryRun }) {
|
||||
if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:scorers`, scorers, SCORERS_TTL_SEC);
|
||||
}
|
||||
|
||||
// ---- api-football enrichment (PRIMARY cascade write) ----
|
||||
if (shouldRunSource(args, 'api-football')) {
|
||||
const apifResult = await enrichFromApiFootball(league, args);
|
||||
summary.apiFootballPlayers = apifResult.players || 0;
|
||||
if (apifResult.skipped) summary.apiFootballSkipped = apifResult.skipped;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -200,10 +402,16 @@ async function main(argv = process.argv) {
|
||||
const args = parseArgs(argv);
|
||||
const startTs = Date.now();
|
||||
|
||||
console.log(`[soccer-prefetch] starting — leagues=${args.leagues.join(',')} dry_run=${args.dryRun}`);
|
||||
console.log(`[soccer-prefetch] starting — leagues=${args.leagues.join(',')} source=${args.source} max_players=${args.maxPlayers} dry_run=${args.dryRun}`);
|
||||
|
||||
if (!fbd.hasApiKey()) {
|
||||
console.warn('[soccer-prefetch] FOOTBALL_DATA_API_KEY not set — skipping. WC fixtures still flow via the OSS API in poller/soccer.js; non-WC leagues are no-ops until the key is configured.');
|
||||
// Skip only if EVERY configured source is unavailable. Previously
|
||||
// we bailed when football-data was unset, but now api-football can
|
||||
// carry the load on its own.
|
||||
const fbdReady = fbd.hasApiKey() && shouldRunSource(args, 'football-data');
|
||||
const apifReady = apif.hasApiKey() && shouldRunSource(args, 'api-football');
|
||||
const footapiReady = footapi.hasApiKey() && shouldRunSource(args, 'footapi');
|
||||
if (!fbdReady && !apifReady && !footapiReady) {
|
||||
console.warn('[soccer-prefetch] no source keys configured — nothing to fetch. Static data + poller OSS fallback continue to work.');
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
@@ -212,7 +420,7 @@ async function main(argv = process.argv) {
|
||||
try {
|
||||
const r = await processLeague(league, args);
|
||||
results.push(r);
|
||||
console.log(`[soccer-prefetch] ${league}: standings=${r.standings} scorers=${r.scorers} players=${r.players} teamDefense=${r.teamDefense} ${r.skipped ? '(skipped: no_data)' : ''}`);
|
||||
console.log(`[soccer-prefetch] ${league}: standings=${r.standings} scorers=${r.scorers} players=${r.players} teamDefense=${r.teamDefense} apifootball=${r.apiFootballPlayers || 0}${r.apiFootballSkipped ? `(${r.apiFootballSkipped})` : ''} ${r.skipped ? '(skipped: no_data)' : ''}`);
|
||||
} catch (err) {
|
||||
console.warn(`[soccer-prefetch] ${league} failed:`, err.message);
|
||||
results.push({ league, error: err.message });
|
||||
@@ -235,12 +443,17 @@ module.exports = {
|
||||
main,
|
||||
__internals: {
|
||||
parseArgs,
|
||||
shouldRunSource,
|
||||
aggregateTeamDefense,
|
||||
aggregatePlayerFromScorer,
|
||||
enrichFromApiFootball,
|
||||
enrichRefereesFromFootApi,
|
||||
processLeague,
|
||||
APIFOOTBALL_LEAGUE_MAP,
|
||||
PLAYER_TTL_SEC,
|
||||
STANDINGS_TTL_SEC,
|
||||
SCORERS_TTL_SEC,
|
||||
DEFENSE_TTL_SEC,
|
||||
REFEREE_TTL_SEC,
|
||||
},
|
||||
};
|
||||
|
||||
+12
@@ -1,4 +1,10 @@
|
||||
require('dotenv').config();
|
||||
// Session 10 — Sentry must initialize BEFORE express is required so
|
||||
// the instrumentation hooks attach correctly. Graceful no-op when
|
||||
// SENTRY_DSN is unset.
|
||||
const { initSentry, Sentry } = require('./utils/sentry');
|
||||
initSentry();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const oddsRoutes = require('./routes/odds');
|
||||
@@ -132,4 +138,10 @@ app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes);
|
||||
const widgetRoutes = require('./routes/widget');
|
||||
app.use('/api/widget', widgetRoutes);
|
||||
|
||||
// Session 10 — Sentry's Express error handler catches uncaught
|
||||
// errors from every route mounted above. Must come AFTER routes but
|
||||
// BEFORE any final express error handler. The noop client makes this
|
||||
// a safe no-op when SENTRY_DSN is unset.
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Internal authentication middleware (Session 10).
|
||||
*
|
||||
* Protects internal-only endpoints — the grading pipeline, the
|
||||
* resolution poll-back, the corrections sweep — that are called by
|
||||
* pollers, n8n workflows, and cron jobs but NEVER by browser users.
|
||||
* Uses a shared secret in `VYNDR_INTERNAL_KEY` checked against the
|
||||
* request header.
|
||||
*
|
||||
* Deliberately separate from `requireAuth` (Supabase JWT). Internal
|
||||
* callers don't have user sessions.
|
||||
*
|
||||
* Header compatibility:
|
||||
* `x-internal-key` — Session 10 short form (n8n + new callers)
|
||||
* `X-VYNDR-Internal-Key` — legacy form, kept for backwards
|
||||
* compatibility with the poller and
|
||||
* the existing test suite. The
|
||||
* middleware accepts either; callers
|
||||
* should prefer the short form.
|
||||
*
|
||||
* Options:
|
||||
* loopbackOnly (default false) — additionally enforce that the
|
||||
* request originated from 127.0.0.1 / ::1. Use for endpoints that
|
||||
* should ONLY be reachable from co-located processes (the poller
|
||||
* pulling box scores). Endpoints called from n8n or other
|
||||
* containers MUST omit this option.
|
||||
*
|
||||
* Responses:
|
||||
* 503 — VYNDR_INTERNAL_KEY env var not set (misconfigured)
|
||||
* 401 — header missing OR key mismatch
|
||||
* 403 — loopbackOnly=true and the request came from off-host
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
// Timing-safe string compare. crypto.timingSafeEqual throws on
|
||||
// length mismatch, so we pad to a fixed length first to keep the
|
||||
// comparison constant-time regardless of input length.
|
||||
function timingSafeStringEqual(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
const len = Math.max(a.length, b.length);
|
||||
// Pad with a NUL byte (will never appear in a real key) so the
|
||||
// shorter side still has the same length.
|
||||
const ba = Buffer.alloc(len, 0);
|
||||
const bb = Buffer.alloc(len, 0);
|
||||
ba.write(a, 'utf8');
|
||||
bb.write(b, 'utf8');
|
||||
// Even on length mismatch the compare runs to completion; we just
|
||||
// also require lengths match to count as equal.
|
||||
const equal = crypto.timingSafeEqual(ba, bb);
|
||||
return equal && a.length === b.length;
|
||||
}
|
||||
|
||||
function readHeader(req) {
|
||||
// Express normalizes header names to lowercase. Try the short form
|
||||
// first (the documented one), then the legacy long form.
|
||||
return req.get('x-internal-key') || req.get('X-VYNDR-Internal-Key') || null;
|
||||
}
|
||||
|
||||
function requireInternalAuth(options = {}) {
|
||||
const loopbackOnly = !!options.loopbackOnly;
|
||||
return function internalAuthMiddleware(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) {
|
||||
// Refuse to serve when the secret is unset — better than
|
||||
// accidentally exposing the endpoint with a default empty
|
||||
// value. n8n uses this to distinguish "misconfigured" (503)
|
||||
// from "wrong key" (401).
|
||||
return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
}
|
||||
|
||||
const provided = readHeader(req);
|
||||
if (!provided || !timingSafeStringEqual(provided, expected)) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
|
||||
if (loopbackOnly) {
|
||||
const remoteIp = req.ip || req.socket?.remoteAddress;
|
||||
if (!LOOPBACK_IPS.has(remoteIp)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireInternalAuth,
|
||||
__internals: {
|
||||
LOOPBACK_IPS,
|
||||
timingSafeStringEqual,
|
||||
readHeader,
|
||||
},
|
||||
};
|
||||
@@ -25,19 +25,11 @@ const { __helpers: gradingHelpers } = require('./grading');
|
||||
const router = express.Router();
|
||||
const espnLimiter = createLimiter(API_BUDGETS.espn);
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
if (req.get('X-VYNDR-Internal-Key') !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
if (!LOOPBACK_IPS.has(req.ip || req.socket?.remoteAddress)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
// Session 10 — uses src/middleware/internalAuth.js. /correct stays
|
||||
// loopback-restricted because the morning sweep runs co-located with
|
||||
// the API; n8n doesn't call this one.
|
||||
const { requireInternalAuth } = require('../middleware/internalAuth');
|
||||
const requireInternal = requireInternalAuth({ loopbackOnly: true });
|
||||
|
||||
async function fetchBoxScore(sportCfg, gameId) {
|
||||
await espnLimiter.waitForToken();
|
||||
|
||||
+35
-28
@@ -27,27 +27,22 @@ const clvTracker = require('../services/intelligence/clvTracker');
|
||||
const accuracyTracker = require('../services/intelligence/accuracyTracker');
|
||||
const weightAdjuster = require('../services/intelligence/weightAdjuster');
|
||||
|
||||
const { requireInternalAuth } = require('../middleware/internalAuth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) {
|
||||
// Refuse to serve if the secret isn't configured — better than
|
||||
// accidentally exposing the endpoint with a default value.
|
||||
return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
}
|
||||
const provided = req.get('X-VYNDR-Internal-Key');
|
||||
if (!provided || provided !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
const remoteIp = req.ip || req.socket?.remoteAddress;
|
||||
if (!LOOPBACK_IPS.has(remoteIp)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
// Session 10 — extracted into src/middleware/internalAuth.js. The two
|
||||
// internal routes below keep slightly different policies:
|
||||
// /resolve — loopback-only (called by the on-host poller)
|
||||
// /pipeline — accepts off-host (called by n8n from another container,
|
||||
// which is why the legacy loopback-only check broke it)
|
||||
//
|
||||
// `requireInternal` stays exported as `__helpers.requireInternal` for
|
||||
// the existing test suite — it's a thin alias for the loopback-only
|
||||
// variant so the resolution test (which spins up its own server on
|
||||
// 127.0.0.1) behaves identically.
|
||||
const requireInternal = requireInternalAuth({ loopbackOnly: true });
|
||||
const requireInternalAnyOrigin = requireInternalAuth({ loopbackOnly: false });
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Box-score traversal — sport-specific shapes flattened into a uniform
|
||||
@@ -412,22 +407,34 @@ router.post('/resolve', requireInternal, async (req, res) => {
|
||||
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']);
|
||||
|
||||
router.post('/pipeline', requireInternal, async (req, res) => {
|
||||
// Session 10 — `/pipeline` accepts off-host callers (n8n runs in a
|
||||
// separate container). With a `sport` body field, runs that sport
|
||||
// only; with an empty body, iterates every active sport. n8n's
|
||||
// Morning Ops workflow sends an empty body; the per-sport workflows
|
||||
// pass a specific sport. The legacy header (X-VYNDR-Internal-Key) and
|
||||
// the new short form (x-internal-key) both authenticate.
|
||||
router.post('/pipeline', requireInternalAnyOrigin, async (req, res) => {
|
||||
const { sport, options } = req.body || {};
|
||||
if (!sport || !VALID_SPORTS.has(sport)) {
|
||||
if (sport && !VALID_SPORTS.has(sport)) {
|
||||
return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' });
|
||||
}
|
||||
// Lazy-load the orchestrator so this route doesn't pay the require cost
|
||||
// until it's actually invoked (and so unit tests of /resolve don't pull
|
||||
// in the whole adapter graph).
|
||||
const { runPipeline } = require('../services/intelligence/gradingOrchestrator');
|
||||
const sportsToRun = sport ? [sport] : ['nba', 'wnba', 'mlb'];
|
||||
const results = [];
|
||||
for (const s of sportsToRun) {
|
||||
try {
|
||||
const summary = await runPipeline(sport, options || {});
|
||||
return res.json(summary);
|
||||
const summary = await runPipeline(s, options || {});
|
||||
results.push({ sport: s, ...summary });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Pipeline error:', err.message);
|
||||
return res.status(503).json({ error: 'Pipeline run failed' });
|
||||
console.error('[VYNDR] Pipeline error:', s, err.message);
|
||||
results.push({ sport: s, error: err.message });
|
||||
}
|
||||
}
|
||||
// If a specific sport was requested, preserve the legacy single-object
|
||||
// shape so existing callers (tests + the n8n per-sport workflows)
|
||||
// don't break. Multi-sport runs return an array.
|
||||
if (sport) return res.json(results[0]);
|
||||
return res.json({ status: 'ok', timestamp: new Date().toISOString(), sports: results });
|
||||
});
|
||||
|
||||
// Exported so server.js can wire it up with a larger body limit; also lets
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Sentry init for the Express backend (Session 10).
|
||||
*
|
||||
* Boot is GRACEFUL when SENTRY_DSN is unset: `initSentry()` no-ops and
|
||||
* the module's exported `Sentry` is a stub whose every method is a
|
||||
* noop. This lets callers wire `Sentry.captureException(...)` etc.
|
||||
* unconditionally — when the DSN isn't configured (dev, CI, tests),
|
||||
* nothing crashes and no events are sent.
|
||||
*
|
||||
* PII posture: `sendDefaultPii: false` plus an `events`-stage hook
|
||||
* that strips `user.ip_address` and `request.cookies`. We log code
|
||||
* paths and errors, not user identities.
|
||||
*
|
||||
* Sample rate: 10% traces (free tier friendly). 100% errors.
|
||||
*/
|
||||
|
||||
const realSentry = require('@sentry/node');
|
||||
|
||||
// Initialized lazily so this module is safe to require even when the
|
||||
// DSN is absent.
|
||||
let _initialized = false;
|
||||
|
||||
function buildClient() {
|
||||
return {
|
||||
init: realSentry.init,
|
||||
captureException: realSentry.captureException,
|
||||
captureMessage: realSentry.captureMessage,
|
||||
setupExpressErrorHandler: realSentry.setupExpressErrorHandler,
|
||||
addBreadcrumb: realSentry.addBreadcrumb,
|
||||
setUser: realSentry.setUser,
|
||||
setTag: realSentry.setTag,
|
||||
setContext: realSentry.setContext,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNoop() {
|
||||
// Every Sentry surface used in the codebase returns either undefined
|
||||
// (most) or a no-op express middleware (`setupExpressErrorHandler`).
|
||||
const noopMiddleware = (_req, _res, next) => next();
|
||||
return {
|
||||
init: () => {},
|
||||
captureException: () => {},
|
||||
captureMessage: () => {},
|
||||
setupExpressErrorHandler: (_app) => {
|
||||
// The real one mutates the app; the noop simply returns without
|
||||
// attaching anything. The caller pattern is:
|
||||
// Sentry.setupExpressErrorHandler(app);
|
||||
// which evaluates the call for its side effects.
|
||||
},
|
||||
addBreadcrumb: () => {},
|
||||
setUser: () => {},
|
||||
setTag: () => {},
|
||||
setContext: () => {},
|
||||
Handlers: { errorHandler: () => noopMiddleware },
|
||||
};
|
||||
}
|
||||
|
||||
let Sentry = buildNoop();
|
||||
|
||||
function initSentry({ dsn = process.env.SENTRY_DSN, environment = process.env.NODE_ENV, release } = {}) {
|
||||
if (_initialized) return Sentry;
|
||||
if (!dsn) {
|
||||
// Stay on the noop client.
|
||||
return Sentry;
|
||||
}
|
||||
Sentry = buildClient();
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: environment || 'development',
|
||||
release,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
// PII scrubbing. Sentry occasionally fills these via auto-context.
|
||||
if (event.user) {
|
||||
delete event.user.ip_address;
|
||||
delete event.user.email;
|
||||
}
|
||||
if (event.request) {
|
||||
delete event.request.cookies;
|
||||
// Strip the Authorization header to avoid logging bearer tokens.
|
||||
if (event.request.headers) {
|
||||
delete event.request.headers.authorization;
|
||||
delete event.request.headers.cookie;
|
||||
delete event.request.headers['x-internal-key'];
|
||||
delete event.request.headers['x-vyndr-internal-key'];
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
_initialized = true;
|
||||
return Sentry;
|
||||
}
|
||||
|
||||
function getSentry() {
|
||||
return Sentry;
|
||||
}
|
||||
|
||||
function isInitialized() {
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
// Test helper — reset to the noop client so subsequent initSentry()
|
||||
// calls re-run. Not exported via the main surface; live behind __internals.
|
||||
function __resetForTests() {
|
||||
_initialized = false;
|
||||
Sentry = buildNoop();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initSentry,
|
||||
getSentry,
|
||||
isInitialized,
|
||||
// Convenience re-export so callers can do `const { Sentry } = require(...)`.
|
||||
// Note: this is a live binding — mutated by initSentry() on first call.
|
||||
get Sentry() { return Sentry; },
|
||||
__internals: { __resetForTests, buildNoop, buildClient },
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// internalAuth middleware (Session 10) — tests both the new short-form
|
||||
// header (x-internal-key) and the legacy long form
|
||||
// (X-VYNDR-Internal-Key), the timing-safe compare, and the optional
|
||||
// loopback restriction.
|
||||
|
||||
const { requireInternalAuth, __internals } = require('../../src/middleware/internalAuth');
|
||||
|
||||
function fakeReqRes({ headers = {}, ip = '127.0.0.1' } = {}) {
|
||||
const req = {
|
||||
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
||||
ip,
|
||||
socket: { remoteAddress: ip },
|
||||
get(name) {
|
||||
return this.headers[name.toLowerCase()];
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
function run(mw, req, res) {
|
||||
return new Promise((resolve) => {
|
||||
mw(req, res, () => resolve('next'));
|
||||
if (res.statusCode != null) resolve('blocked');
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.VYNDR_INTERNAL_KEY;
|
||||
});
|
||||
|
||||
describe('requireInternalAuth', () => {
|
||||
describe('configuration guard', () => {
|
||||
test('returns 503 when VYNDR_INTERNAL_KEY env var is unset', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'anything' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.body.error).toMatch(/not configured/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('header compatibility', () => {
|
||||
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
||||
|
||||
test('accepts the new short-form header (x-internal-key)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('accepts the legacy long-form header (X-VYNDR-Internal-Key)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-vyndr-internal-key': 'real-key-12345' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('returns 401 on missing header', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({});
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 on wrong key (timing-safe)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'wrong-key' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 even when prefix matches but suffix differs', async () => {
|
||||
// Guards against early-exit string compare leaking timing info.
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-1234X' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test('returns 401 on length mismatch (avoids any timingSafeEqual throw)', async () => {
|
||||
const mw = requireInternalAuth();
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'short' } });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loopbackOnly option', () => {
|
||||
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
||||
|
||||
test('passes from 127.0.0.1', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '127.0.0.1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('passes from IPv6 loopback', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('passes from IPv4-mapped IPv6 loopback', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::ffff:127.0.0.1' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
|
||||
test('returns 403 from off-host IP', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: true });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '10.0.0.42' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('blocked');
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toMatch(/Origin not permitted/);
|
||||
});
|
||||
|
||||
test('WITHOUT loopbackOnly, off-host IPs are accepted (n8n case)', async () => {
|
||||
const mw = requireInternalAuth({ loopbackOnly: false });
|
||||
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '172.18.0.3' });
|
||||
const out = await run(mw, req, res);
|
||||
expect(out).toBe('next');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timingSafeStringEqual', () => {
|
||||
const { timingSafeStringEqual } = __internals;
|
||||
|
||||
test('equal strings → true', () => {
|
||||
expect(timingSafeStringEqual('abc123', 'abc123')).toBe(true);
|
||||
});
|
||||
test('different strings same length → false', () => {
|
||||
expect(timingSafeStringEqual('abc123', 'abc124')).toBe(false);
|
||||
});
|
||||
test('different lengths → false (does not throw)', () => {
|
||||
expect(() => timingSafeStringEqual('a', 'abc')).not.toThrow();
|
||||
expect(timingSafeStringEqual('a', 'abc')).toBe(false);
|
||||
});
|
||||
test('non-string inputs → false', () => {
|
||||
expect(timingSafeStringEqual(null, 'a')).toBe(false);
|
||||
expect(timingSafeStringEqual('a', undefined)).toBe(false);
|
||||
expect(timingSafeStringEqual(123, '123')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Sentry init wrapper — guarantees graceful no-op when SENTRY_DSN is
|
||||
// absent, scrubs PII on send when configured, and exposes a usable
|
||||
// surface to the rest of the codebase regardless of init state.
|
||||
|
||||
// Mock @sentry/node BEFORE requiring our wrapper so we control what
|
||||
// init() and setupExpressErrorHandler do.
|
||||
const mockSentryInit = jest.fn();
|
||||
const mockSentryCapture = jest.fn();
|
||||
const mockSentryHandler = jest.fn();
|
||||
jest.mock('@sentry/node', () => ({
|
||||
init: (...a) => mockSentryInit(...a),
|
||||
captureException: (...a) => mockSentryCapture(...a),
|
||||
captureMessage: jest.fn(),
|
||||
setupExpressErrorHandler: (...a) => mockSentryHandler(...a),
|
||||
addBreadcrumb: jest.fn(),
|
||||
setUser: jest.fn(),
|
||||
setTag: jest.fn(),
|
||||
setContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const sentryWrapper = require('../../src/utils/sentry');
|
||||
|
||||
beforeEach(() => {
|
||||
mockSentryInit.mockReset();
|
||||
mockSentryCapture.mockReset();
|
||||
mockSentryHandler.mockReset();
|
||||
sentryWrapper.__internals.__resetForTests();
|
||||
delete process.env.SENTRY_DSN;
|
||||
});
|
||||
|
||||
describe('initSentry', () => {
|
||||
test('no-op when SENTRY_DSN is unset (sentry.init NOT called)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).not.toHaveBeenCalled();
|
||||
expect(sentryWrapper.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
test('initializes when SENTRY_DSN is set', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).toHaveBeenCalledTimes(1);
|
||||
expect(sentryWrapper.isInitialized()).toBe(true);
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
expect(cfg.dsn).toBe('https://abc@sentry.io/123');
|
||||
expect(cfg.tracesSampleRate).toBe(0.1);
|
||||
expect(cfg.sendDefaultPii).toBe(false);
|
||||
expect(typeof cfg.beforeSend).toBe('function');
|
||||
});
|
||||
|
||||
test('explicit dsn arg overrides env', () => {
|
||||
process.env.SENTRY_DSN = 'env-dsn';
|
||||
sentryWrapper.initSentry({ dsn: 'arg-dsn' });
|
||||
expect(mockSentryInit.mock.calls[0][0].dsn).toBe('arg-dsn');
|
||||
});
|
||||
|
||||
test('idempotent — second call is a no-op', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
sentryWrapper.initSentry();
|
||||
expect(mockSentryInit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeSend PII scrubbing', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
});
|
||||
|
||||
test('strips user.ip_address and user.email', () => {
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
const event = { user: { id: 'u1', ip_address: '1.2.3.4', email: 'a@b.com' } };
|
||||
const scrubbed = cfg.beforeSend(event);
|
||||
expect(scrubbed.user.id).toBe('u1');
|
||||
expect(scrubbed.user.ip_address).toBeUndefined();
|
||||
expect(scrubbed.user.email).toBeUndefined();
|
||||
});
|
||||
|
||||
test('strips cookies + authorization + internal-key headers from request', () => {
|
||||
const cfg = mockSentryInit.mock.calls[0][0];
|
||||
const event = {
|
||||
request: {
|
||||
cookies: { session: 'abc' },
|
||||
headers: {
|
||||
authorization: 'Bearer secret',
|
||||
cookie: 'session=abc',
|
||||
'x-internal-key': 'leak',
|
||||
'x-vyndr-internal-key': 'leak',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
const scrubbed = cfg.beforeSend(event);
|
||||
expect(scrubbed.request.cookies).toBeUndefined();
|
||||
expect(scrubbed.request.headers.authorization).toBeUndefined();
|
||||
expect(scrubbed.request.headers.cookie).toBeUndefined();
|
||||
expect(scrubbed.request.headers['x-internal-key']).toBeUndefined();
|
||||
expect(scrubbed.request.headers['x-vyndr-internal-key']).toBeUndefined();
|
||||
// Non-sensitive headers are kept.
|
||||
expect(scrubbed.request.headers['content-type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noop client surface', () => {
|
||||
test('captureException is callable without DSN (no throw)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(() => sentryWrapper.Sentry.captureException(new Error('test'))).not.toThrow();
|
||||
expect(mockSentryCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setupExpressErrorHandler is callable without DSN (no throw)', () => {
|
||||
sentryWrapper.initSentry();
|
||||
expect(() => sentryWrapper.Sentry.setupExpressErrorHandler({})).not.toThrow();
|
||||
expect(mockSentryHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('after init, captureException routes to real Sentry', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
const err = new Error('boom');
|
||||
sentryWrapper.Sentry.captureException(err);
|
||||
expect(mockSentryCapture).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
test('after init, setupExpressErrorHandler delegates to real Sentry', () => {
|
||||
process.env.SENTRY_DSN = 'https://abc@sentry.io/123';
|
||||
sentryWrapper.initSentry();
|
||||
const app = {};
|
||||
sentryWrapper.Sentry.setupExpressErrorHandler(app);
|
||||
expect(mockSentryHandler).toHaveBeenCalledWith(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// Session 10 — prefetch's new api-football enrichment pass and the
|
||||
// CLI flags (--source, --max-players). The existing soccerDataPrefetch
|
||||
// test suite covers the legacy football-data path; this suite verifies:
|
||||
// - parseArgs handles the new flags
|
||||
// - shouldRunSource respects the source filter + defaults to 'all'
|
||||
// - enrichFromApiFootball walks finished fixtures, aggregates per-90
|
||||
// rates, and writes apifootball:player_by_name:{normalizedName}
|
||||
// - graceful skip when API_FOOTBALL_KEY is unset
|
||||
|
||||
const mockApifGetFixtures = jest.fn();
|
||||
const mockApifGetFixturePlayerStats = jest.fn();
|
||||
const mockApifHasApiKey = jest.fn(() => true);
|
||||
jest.mock('../../src/services/adapters/apiFootballAdapter', () => ({
|
||||
getFixtures: (...a) => mockApifGetFixtures(...a),
|
||||
getFixturePlayerStats: (...a) => mockApifGetFixturePlayerStats(...a),
|
||||
hasApiKey: (...a) => mockApifHasApiKey(...a),
|
||||
}));
|
||||
|
||||
const mockFootapiHasApiKey = jest.fn(() => false);
|
||||
const mockFootapiGetRefereeStatistics = jest.fn();
|
||||
jest.mock('../../src/services/adapters/footApiAdapter', () => ({
|
||||
hasApiKey: (...a) => mockFootapiHasApiKey(...a),
|
||||
getRefereeStatistics: (...a) => mockFootapiGetRefereeStatistics(...a),
|
||||
}));
|
||||
|
||||
const mockFbdHasApiKey = jest.fn(() => true);
|
||||
const mockFbdGetLeagueStandings = jest.fn(async () => []);
|
||||
const mockFbdGetLeagueScorers = jest.fn(async () => []);
|
||||
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
||||
hasApiKey: (...a) => mockFbdHasApiKey(...a),
|
||||
getLeagueStandings: (...a) => mockFbdGetLeagueStandings(...a),
|
||||
getLeagueScorers: (...a) => mockFbdGetLeagueScorers(...a),
|
||||
}));
|
||||
|
||||
const mockCacheSets = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
const prefetch = require('../../scripts/soccer-data-prefetch');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApifGetFixtures.mockReset();
|
||||
mockApifGetFixturePlayerStats.mockReset();
|
||||
mockApifHasApiKey.mockReset().mockReturnValue(true);
|
||||
mockFbdHasApiKey.mockReset().mockReturnValue(true);
|
||||
mockFbdGetLeagueStandings.mockReset().mockResolvedValue([]);
|
||||
mockFbdGetLeagueScorers.mockReset().mockResolvedValue([]);
|
||||
mockFootapiHasApiKey.mockReset().mockReturnValue(false);
|
||||
mockFootapiGetRefereeStatistics.mockReset();
|
||||
mockCacheSets.clear();
|
||||
});
|
||||
|
||||
describe('soccer-data-prefetch — Session 10 cascade enrichment', () => {
|
||||
describe('parseArgs', () => {
|
||||
test('parses --source flag with valid value', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--source=api-football']);
|
||||
expect(a.source).toBe('api-football');
|
||||
});
|
||||
test('invalid --source falls back to "all"', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--source=bogus']);
|
||||
expect(a.source).toBe('all');
|
||||
});
|
||||
test('parses --max-players', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--max-players=25']);
|
||||
expect(a.maxPlayers).toBe(25);
|
||||
});
|
||||
test('--max-players ignores non-numeric and zero/negative', () => {
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=foo']).maxPlayers).toBe(80);
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=0']).maxPlayers).toBe(80);
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--max-players=-5']).maxPlayers).toBe(80);
|
||||
});
|
||||
test('parses --season', () => {
|
||||
expect(prefetch.__internals.parseArgs(['node', 'script', '--season=2025']).season).toBe(2025);
|
||||
});
|
||||
test('defaults', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script']);
|
||||
expect(a.source).toBe('all');
|
||||
expect(a.maxPlayers).toBe(80);
|
||||
expect(a.season).toBe(2026);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunSource', () => {
|
||||
const { shouldRunSource } = prefetch.__internals;
|
||||
test('"all" matches everything', () => {
|
||||
expect(shouldRunSource({ source: 'all' }, 'api-football')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'all' }, 'football-data')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'all' }, 'footapi')).toBe(true);
|
||||
});
|
||||
test('explicit source matches only itself', () => {
|
||||
expect(shouldRunSource({ source: 'api-football' }, 'api-football')).toBe(true);
|
||||
expect(shouldRunSource({ source: 'api-football' }, 'football-data')).toBe(false);
|
||||
});
|
||||
test('missing args.source defaults to "all" (backwards compat)', () => {
|
||||
expect(shouldRunSource({}, 'football-data')).toBe(true);
|
||||
expect(shouldRunSource(undefined, 'api-football')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichFromApiFootball', () => {
|
||||
test('graceful skip when API_FOOTBALL_KEY is unset', async () => {
|
||||
mockApifHasApiKey.mockReturnValueOnce(false);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('no_key');
|
||||
expect(r.players).toBe(0);
|
||||
expect(mockApifGetFixtures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips an unmapped league code (no api-football league ID)', async () => {
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('UNKNOWN', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('unmapped_league');
|
||||
expect(mockApifGetFixtures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips when no fixtures returned', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.skipped).toBe('no_fixtures');
|
||||
});
|
||||
|
||||
test('aggregates per-90 stats across finished fixtures, writes cascade keys', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([
|
||||
{ id: 9001, status: 'FT' },
|
||||
{ id: 9002, status: 'NS' }, // not yet played — skip
|
||||
{ id: 9003, status: 'FT' },
|
||||
]);
|
||||
// Fixture 9001: Messi 90min, 1G, 2A, 5 shots.
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'Lionel Messi', team: 'Argentina', position: 'F', minutes: 90, goals: 1, assists: 2, shots_total: 5, shots_on: 3, substitute: false, rating: '8.4' },
|
||||
]);
|
||||
// Fixture 9003: Messi 88min, 0G, 1A.
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'Lionel Messi', team: 'Argentina', position: 'F', minutes: 88, goals: 0, assists: 1, shots_total: 3, shots_on: 1, substitute: false, rating: '7.5' },
|
||||
]);
|
||||
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: false,
|
||||
});
|
||||
expect(r.players).toBe(1);
|
||||
|
||||
const key = `apifootball:player_by_name:${normalizeName('Lionel Messi')}`;
|
||||
const entry = mockCacheSets.get(key);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.value).toMatchObject({
|
||||
name: 'Lionel Messi', team: 'Argentina',
|
||||
appearances: 2,
|
||||
minutes: 178, // 90 + 88
|
||||
goals: 1, assists: 3,
|
||||
});
|
||||
// 1 goal over 178 minutes = 0.506 per 90 (3dp).
|
||||
expect(entry.value.goals_per_90).toBeCloseTo(0.506, 2);
|
||||
// 3 assists over 178 min = 1.517 per 90.
|
||||
expect(entry.value.assists_per_90).toBeCloseTo(1.517, 2);
|
||||
// Average rating across two appearances.
|
||||
expect(entry.value.avg_rating).toBeCloseTo(7.95, 1);
|
||||
expect(entry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC);
|
||||
});
|
||||
|
||||
test('honors maxPlayers cap (limits writes per run)', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([{ id: 1, status: 'FT' }]);
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'A', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
{ name: 'B', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
{ name: 'C', team: 'X', minutes: 90, goals: 0, assists: 0 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 2, dryRun: false,
|
||||
});
|
||||
expect(r.players).toBe(2);
|
||||
});
|
||||
|
||||
test('dry-run skips cacheSet but still reports a count', async () => {
|
||||
mockApifGetFixtures.mockResolvedValueOnce([{ id: 1, status: 'FT' }]);
|
||||
mockApifGetFixturePlayerStats.mockResolvedValueOnce([
|
||||
{ name: 'A', team: 'X', minutes: 90, goals: 1, assists: 0 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichFromApiFootball('WC', {
|
||||
season: 2026, maxPlayers: 80, dryRun: true,
|
||||
});
|
||||
expect(r.players).toBe(1);
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichRefereesFromFootApi', () => {
|
||||
test('graceful skip when RAPID_API_KEY is unset', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValueOnce(false);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: 1, name: 'X' }], { dryRun: false },
|
||||
);
|
||||
expect(r.skipped).toBe('no_key');
|
||||
expect(mockFootapiGetRefereeStatistics).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('writes footapi:referee_by_name:{name} keys when stats exist', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValue(true);
|
||||
mockFootapiGetRefereeStatistics.mockResolvedValueOnce([
|
||||
{ tournamentId: 16, appearances: 6, yellowCards: 24, redCards: 1, yellowCardsPerGame: 4 },
|
||||
]);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: 99, name: 'Anthony Taylor' }], { dryRun: false },
|
||||
);
|
||||
expect(r.referees).toBe(1);
|
||||
const entry = mockCacheSets.get('footapi:referee_by_name:Anthony Taylor');
|
||||
expect(entry.value).toMatchObject({
|
||||
name: 'Anthony Taylor', cards_per_game: 4, appearances: 6,
|
||||
});
|
||||
expect(entry.ttl).toBe(prefetch.__internals.REFEREE_TTL_SEC);
|
||||
});
|
||||
|
||||
test('handles missing IDs in the referee list', async () => {
|
||||
mockFootapiHasApiKey.mockReturnValue(true);
|
||||
const r = await prefetch.__internals.enrichRefereesFromFootApi(
|
||||
[{ id: null, name: 'X' }, { id: 1, name: null }], { dryRun: false },
|
||||
);
|
||||
expect(r.referees).toBe(0);
|
||||
expect(mockFootapiGetRefereeStatistics).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('main — graceful skip when no source keys configured', () => {
|
||||
test('logs skip + returns {skipped: true} when nothing is available', async () => {
|
||||
mockFbdHasApiKey.mockReturnValue(false);
|
||||
mockApifHasApiKey.mockReturnValue(false);
|
||||
mockFootapiHasApiKey.mockReturnValue(false);
|
||||
const r = await prefetch.main(['node', 'script']);
|
||||
expect(r.skipped).toBe(true);
|
||||
});
|
||||
|
||||
test('proceeds when ANY source is available (api-football only)', async () => {
|
||||
mockFbdHasApiKey.mockReturnValue(false);
|
||||
mockApifHasApiKey.mockReturnValue(true);
|
||||
mockApifGetFixtures.mockResolvedValue([]);
|
||||
const r = await prefetch.main(['node', 'script', '--leagues=WC', '--dry-run']);
|
||||
// Not skipped — we have at least one configured source.
|
||||
expect(r.skipped).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+2207
-25
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "^10.57.0",
|
||||
"@serwist/next": "^9.5.11",
|
||||
"@supabase/supabase-js": "2.99.3",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
|
||||
import { getServiceRoleSupabase } from '@/lib/supabase';
|
||||
import { sendWelcomeEmail } from '@/services/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Welcome email trigger (Session 10).
|
||||
*
|
||||
* POST /api/welcome-email
|
||||
* Bearer auth required.
|
||||
*
|
||||
* Idempotent. The send-once flag lives on Supabase auth user_metadata
|
||||
* (`welcome_email_sent`) — no migration needed; user_metadata is a
|
||||
* JSONB field Supabase exposes by default. We read it via the
|
||||
* service-role admin API so the user can't spoof it from the browser.
|
||||
*
|
||||
* Response codes:
|
||||
* 200 { sent: true, id } — email sent successfully
|
||||
* 200 { sent: false, reason } — skipped (already sent, missing
|
||||
* RESEND_API_KEY, etc.)
|
||||
* 401 — auth missing
|
||||
* 500 — unexpected server error
|
||||
*
|
||||
* The page that calls this (web/src/app/welcome/page.tsx) treats any
|
||||
* 200 response as success regardless of `sent`. Errors are surfaced
|
||||
* to Sentry, not to the user — a missed welcome email never blocks
|
||||
* onboarding.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) return jsonError(401, 'Authentication required');
|
||||
if (!user.email) {
|
||||
return NextResponse.json({ sent: false, reason: 'no_email_on_account' });
|
||||
}
|
||||
|
||||
const sb = getServiceRoleSupabase();
|
||||
if (!sb) {
|
||||
// Without service-role we can't read/write user_metadata; bail
|
||||
// softly so the welcome page render isn't blocked.
|
||||
return NextResponse.json({ sent: false, reason: 'no_service_role' });
|
||||
}
|
||||
|
||||
// Read the auth user via the admin API to inspect user_metadata.
|
||||
let metadata: Record<string, unknown> = {};
|
||||
try {
|
||||
const { data, error } = await sb.auth.admin.getUserById(user.id);
|
||||
if (error || !data?.user) {
|
||||
return NextResponse.json({ sent: false, reason: 'user_not_found' });
|
||||
}
|
||||
metadata = (data.user.user_metadata as Record<string, unknown>) || {};
|
||||
} catch {
|
||||
return NextResponse.json({ sent: false, reason: 'metadata_read_failed' });
|
||||
}
|
||||
|
||||
if (metadata.welcome_email_sent === true) {
|
||||
return NextResponse.json({ sent: false, reason: 'already_sent' });
|
||||
}
|
||||
|
||||
// Send + flag. We flag REGARDLESS of send outcome — a Resend
|
||||
// outage shouldn't queue infinite retries from the welcome page.
|
||||
// Operators can manually clear the flag if a batch needs to be
|
||||
// re-sent.
|
||||
const result = await sendWelcomeEmail(user.email);
|
||||
|
||||
try {
|
||||
await sb.auth.admin.updateUserById(user.id, {
|
||||
user_metadata: { ...metadata, welcome_email_sent: true, welcome_email_sent_at: new Date().toISOString() },
|
||||
});
|
||||
} catch {
|
||||
// Flag write failed — log via Sentry if wired; the duplicate-
|
||||
// suppression layer below will eventually catch up.
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ sent: false, reason: result.error || 'send_failed' });
|
||||
}
|
||||
return NextResponse.json({ sent: true, id: result.id });
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import PushPrompt from '@/components/PushPrompt';
|
||||
import MFAPrompt from '@/components/MFAPrompt';
|
||||
import MFAChallenge from '@/components/MFAChallenge';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
import SentryInit from '@/components/SentryInit';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -109,6 +110,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<MFAPrompt />
|
||||
<MFAChallenge />
|
||||
<CookieConsent />
|
||||
<SentryInit />
|
||||
</ParlayProvider>
|
||||
</ExplainModeProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -2,16 +2,34 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function WelcomePage() {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
const { user, session, loading } = useAuth();
|
||||
const welcomeFiredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) router.replace('/signup');
|
||||
}, [loading, user, router]);
|
||||
|
||||
// Session 10 — trigger the welcome email exactly once per mount.
|
||||
// The server-side handler is idempotent via user_metadata
|
||||
// (welcome_email_sent flag), so re-triggers from a refresh are
|
||||
// safe; the ref is just a UX optimization to avoid duplicate
|
||||
// network requests on this page.
|
||||
useEffect(() => {
|
||||
if (welcomeFiredRef.current) return;
|
||||
if (loading || !user || !session) return;
|
||||
welcomeFiredRef.current = true;
|
||||
fetch('/api/welcome-email', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||
}).catch(() => {
|
||||
// Fail silent — onboarding must not block on email outages.
|
||||
});
|
||||
}, [loading, user, session]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tex-scan"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Client-side Sentry init (Session 10).
|
||||
*
|
||||
* Manual init rather than the @sentry/nextjs `withSentryConfig`
|
||||
* wrapper because that plugin conflicts with standalone output mode
|
||||
* (the Coolify production build). Manual init keeps the bundle
|
||||
* simple: nothing imported when DSN is unset, lazy import via
|
||||
* dynamic import() when it is.
|
||||
*
|
||||
* Mount once at the root layout — repeated mounts get the
|
||||
* Sentry-internal idempotency guard, but we keep the layout single-
|
||||
* mount to avoid unnecessary work.
|
||||
*/
|
||||
export default function SentryInit() {
|
||||
useEffect(() => {
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
if (!dsn) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const Sentry = await import('@sentry/nextjs');
|
||||
if (cancelled) return;
|
||||
Sentry.init({
|
||||
dsn,
|
||||
tracesSampleRate: 0.1,
|
||||
// PII posture: don't sweep up IPs / cookies automatically.
|
||||
sendDefaultPii: false,
|
||||
// Trim heavy integrations we don't need for free-tier volume.
|
||||
integrations: (defaults) => defaults.filter((i) => i.name !== 'Replay'),
|
||||
beforeSend(event) {
|
||||
if (event.user) {
|
||||
delete event.user.ip_address;
|
||||
delete event.user.email;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Sentry init failure is never user-facing — degrade silently.
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -75,24 +75,43 @@ const TEMPLATE_HTML_WRAP = (body: string) => `
|
||||
</body></html>`;
|
||||
|
||||
export async function sendWelcomeEmail(email: string): Promise<SendResult> {
|
||||
const subject = "You're in. Let's grade some props.";
|
||||
// Session 10 — copy updated to reflect the per-day quota (3 free
|
||||
// reads/day), Stripe founder pricing, and the live soccer engine.
|
||||
const site = process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app';
|
||||
const subject = 'Welcome to VYNDR — your reads are ready';
|
||||
const body = `
|
||||
<p style="font-size:16px">Welcome to VYNDR.</p>
|
||||
<p>You have <strong>5 free reads every month</strong>. Pick a game, read a prop, and see what the model thinks.</p>
|
||||
<p>The books have every advantage. Now you have one too.</p>
|
||||
<p style="margin-top:24px"><a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
Open the slate →
|
||||
<p>You have <strong>3 free reads per day</strong>. Every read runs your prop through our intelligence engine — the same signals the books use to set lines.</p>
|
||||
<p><strong>Quick start:</strong></p>
|
||||
<ol style="padding-left:20px;line-height:1.7">
|
||||
<li>Go to <a href="${site}/scan" style="color:#00D4A0">vyndr.app/scan</a></li>
|
||||
<li>Pick a player, stat, and line</li>
|
||||
<li>Hit “Read It” — your grade appears in seconds</li>
|
||||
</ol>
|
||||
<p>When you’re ready for unlimited reads and full reasoning breakdowns, founder pricing starts at <strong>$14.99/mo (locked for life)</strong>: <a href="${site}/pricing" style="color:#00D4A0">vyndr.app/pricing</a></p>
|
||||
<p>The <strong>World Cup 2026 intelligence engine is live</strong> — soccer props are graded with xG regression, altitude impact, referee card rates, and penalty taker signals that nobody else has.</p>
|
||||
<p style="margin-top:24px"><a href="${site}/scan"
|
||||
style="display:inline-block;padding:12px 24px;background:#00D4A0;color:#0A0A0F;text-decoration:none;border-radius:8px;font-weight:700">
|
||||
Read your first prop →
|
||||
</a></p>
|
||||
<p style="margin-top:24px;color:#8A8A9A">See what the market doesn’t.<br>— VYNDR</p>
|
||||
`;
|
||||
const text =
|
||||
`Welcome to VYNDR.
|
||||
|
||||
You have 5 free reads every month. Pick a game, read a prop, and see what the model thinks.
|
||||
You have 3 free reads per day. Every read runs your prop through our intelligence engine — the same signals the books use to set lines.
|
||||
|
||||
The books have every advantage. Now you have one too.
|
||||
Quick start:
|
||||
1. Go to ${site}/scan
|
||||
2. Pick a player, stat, and line
|
||||
3. Hit "Read It" — your grade appears in seconds
|
||||
|
||||
Open the slate: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard
|
||||
When you're ready for unlimited reads and full reasoning breakdowns, founder pricing starts at $14.99/mo (locked for life): ${site}/pricing
|
||||
|
||||
The World Cup 2026 intelligence engine is live — soccer props are graded with xG regression, altitude impact, referee card rates, and penalty taker signals that nobody else has.
|
||||
|
||||
See what the market doesn't.
|
||||
— VYNDR
|
||||
${TEMPLATE_FOOTER}`;
|
||||
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user