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();
+35 -28
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');
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
+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 });
}