Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)

This commit is contained in:
Kev
2026-06-10 20:45:05 -04:00
parent b55dcbd614
commit e5c45ecc8e
22 changed files with 3837 additions and 94 deletions
+137 -1
View File
@@ -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
+19
View File
@@ -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"}
+4 -2
View File
@@ -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? |
+253 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+97
View File
@@ -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,
},
};
+5 -13
View File
@@ -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();
+37 -30
View File
@@ -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');
try {
const summary = await runPipeline(sport, options || {});
return res.json(summary);
} catch (err) {
console.error('[VYNDR] Pipeline error:', err.message);
return res.status(503).json({ error: 'Pipeline run failed' });
const sportsToRun = sport ? [sport] : ['nba', 'wnba', 'mlb'];
const results = [];
for (const s of sportsToRun) {
try {
const summary = await runPipeline(s, options || {});
results.push({ sport: s, ...summary });
} catch (err) {
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
+119
View File
@@ -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 },
};
+160
View File
@@ -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);
});
});
});
+132
View File
@@ -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();
});
});
});
+2207 -25
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -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 });
}
+2
View File
@@ -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>
+20 -2
View File
@@ -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"
+49
View File
@@ -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;
}
+28 -9
View File
@@ -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 &ldquo;Read It&rdquo; — your grade appears in seconds</li>
</ol>
<p>When you&rsquo;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&rsquo;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 });
}