From 557925a70d9e08c1ec1e31385e1a3bb417e9a74a Mon Sep 17 00:00:00 2001 From: Gerry Date: Thu, 12 Mar 2026 08:23:44 -0400 Subject: [PATCH 1/2] Harden internal worker boundaries --- .github/workflows/deploy-workers.yml | 13 + web/.env.example | 2 +- web/README.md | 4 +- web/app/api/espn/auto-pull/route.ts | 213 +----- web/app/api/espn/discover-seasons/route.ts | 56 +- web/lib/server/espn-onboarding.ts | 667 ++++++++++++++++++ workers/auth-worker/README.md | 32 +- .../src/__tests__/eval-api-key.test.ts | 99 ++- workers/auth-worker/src/index-hono.ts | 281 ++++++-- workers/auth-worker/wrangler.jsonc | 51 +- workers/espn-client/README.md | 5 +- .../espn-client/src/__tests__/types.test.ts | 3 +- workers/espn-client/src/index.ts | 65 +- .../src/onboarding/basic-league-info.ts | 185 ----- .../espn-client/src/onboarding/handlers.ts | 520 -------------- workers/espn-client/src/shared/auth.ts | 2 +- workers/espn-client/src/types.ts | 1 - workers/espn-client/wrangler.jsonc | 4 +- .../fantasy-mcp/src/__tests__/router.test.ts | 7 +- .../fantasy-mcp/src/__tests__/tools.test.ts | 9 +- workers/fantasy-mcp/src/index.ts | 7 +- workers/fantasy-mcp/src/mcp/tools.ts | 32 +- workers/fantasy-mcp/src/router.ts | 10 +- .../integration/index.integration.test.ts | 14 +- workers/shared/src/auth-fetch.ts | 22 +- workers/shared/src/index.ts | 9 + workers/shared/src/internal-service.ts | 51 ++ workers/shared/src/types.ts | 1 + workers/sleeper-client/README.md | 3 +- .../src/__tests__/routing.test.ts | 35 +- workers/sleeper-client/src/index.ts | 27 +- workers/sleeper-client/src/types.ts | 1 - workers/sleeper-client/wrangler.jsonc | 4 +- workers/yahoo-client/README.md | 3 +- workers/yahoo-client/src/index.ts | 30 +- workers/yahoo-client/src/shared/auth.ts | 4 +- workers/yahoo-client/src/types.ts | 1 - workers/yahoo-client/wrangler.jsonc | 4 +- 38 files changed, 1288 insertions(+), 1189 deletions(-) create mode 100644 web/lib/server/espn-onboarding.ts delete mode 100644 workers/espn-client/src/onboarding/basic-league-info.ts delete mode 100644 workers/espn-client/src/onboarding/handlers.ts create mode 100644 workers/shared/src/internal-service.ts diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index dc78fa14..af6486d3 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -34,6 +34,19 @@ jobs: run: npm run type-check working-directory: workers/${{ matrix.worker }} + - name: Validate auth-worker Wrangler bindings + if: matrix.worker == 'auth-worker' + working-directory: workers/auth-worker + run: | + output="$(npx wrangler deploy --dry-run --env dev 2>&1)" + printf '%s\n' "$output" + if printf '%s\n' "$output" | grep -q 'Unexpected fields'; then + echo "Wrangler config contains unexpected fields" + exit 1 + fi + printf '%s\n' "$output" | grep -q 'env.TOKEN_RATE_LIMITER' + printf '%s\n' "$output" | grep -q 'env.CREDENTIALS_RATE_LIMITER' + deploy: name: Deploy ${{ matrix.worker }} needs: test diff --git a/web/.env.example b/web/.env.example index fdaead04..3bd4c3f8 100644 --- a/web/.env.example +++ b/web/.env.example @@ -27,8 +27,8 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ # Run 'npm run dev' from the project root to start all services NEXT_PUBLIC_AUTH_WORKER_URL=http://localhost:8786 NEXT_PUBLIC_FANTASY_MCP_URL=http://localhost:8790 -NEXT_PUBLIC_ESPN_CLIENT_URL=http://localhost:8789 NEXT_PUBLIC_YAHOO_CLIENT_URL=http://localhost:8791 +INTERNAL_SERVICE_TOKEN=replace-with-shared-internal-token # =========================================== # Environment Configuration diff --git a/web/README.md b/web/README.md index 0469bf9f..7a9acf10 100644 --- a/web/README.md +++ b/web/README.md @@ -102,8 +102,8 @@ CLERK_SECRET_KEY=sk_... # Worker URLs (unified gateway is primary) NEXT_PUBLIC_AUTH_WORKER_URL=https://api.flaim.app/auth NEXT_PUBLIC_FANTASY_MCP_URL=https://api.flaim.app/mcp -# ESPN onboarding (auto-pull + discover seasons) -NEXT_PUBLIC_ESPN_CLIENT_URL=https://espn-client.gerrygugger.workers.dev +# Shared internal token for server-to-worker helper calls +INTERNAL_SERVICE_TOKEN=... # Yahoo MCP client (not called by web directly; useful for local debugging) NEXT_PUBLIC_YAHOO_CLIENT_URL=https://yahoo-client.gerrygugger.workers.dev ``` diff --git a/web/app/api/espn/auto-pull/route.ts b/web/app/api/espn/auto-pull/route.ts index 8b5a2408..2a14e032 100644 --- a/web/app/api/espn/auto-pull/route.ts +++ b/web/app/api/espn/auto-pull/route.ts @@ -1,233 +1,42 @@ -/** - * ESPN Auto-Pull API Route - * --------------------------------------------------------------------------- - * Simplified proxy to sport workers' /onboarding/initialize endpoints. - * Credentials and league management now handled by auth-worker. - */ - import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; -import type { - AutoPullResponse, - EspnLeagueInfo, - SportName -} from '@/lib/espn-types'; +import { runEspnAutoPull } from '@/lib/server/espn-onboarding'; export async function POST(request: NextRequest) { try { const { userId, getToken } = await auth(); - if (!userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } const body = await request.json(); - const { sport, leagueId, seasonYear: requestedSeasonYear } = body as { + const { sport, leagueId, seasonYear } = body as { sport?: string; leagueId?: string; seasonYear?: number; }; - if (!sport || !leagueId) { - return NextResponse.json({ - error: 'Missing required fields: sport and leagueId' - }, { status: 400 }); - } - - // Pass through user's requested year; ESPN-client computes default if omitted. - const seasonYear = requestedSeasonYear; - - console.log(`[auto-pull] Using season year: ${seasonYear} (requested: ${requestedSeasonYear || 'none'})`); - const bearer = await getToken?.(); if (!bearer) { - console.error('[auto-pull] getToken() returned undefined'); return NextResponse.json({ error: 'Authentication token unavailable. Please try signing out and back in.', - code: 'TOKEN_UNAVAILABLE' + code: 'TOKEN_UNAVAILABLE', }, { status: 401 }); } - // Fail fast if the user hasn't saved credentials in auth-worker yet. - const authWorkerUrl = process.env.NEXT_PUBLIC_AUTH_WORKER_URL; - if (!authWorkerUrl) { - return NextResponse.json({ - error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' - }, { status: 500 }); - } - - console.log(`[auto-pull] Auth worker URL: ${authWorkerUrl}`); - console.log(`[auto-pull] Credential pre-check URL: ${authWorkerUrl}/credentials/espn`); - - const credentialCheck = await fetch(`${authWorkerUrl}/credentials/espn`, { - headers: { - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) - }, - cache: 'no-store' + const result = await runEspnAutoPull({ + sport, + leagueId, + seasonYear, + authHeader: `Bearer ${bearer}`, + correlationId: request.headers.get('X-Correlation-ID') || undefined, }); - console.log(`[auto-pull] Credential pre-check response: ${credentialCheck.status}`); - - if (credentialCheck.status === 404) { - console.error('[auto-pull] Credential check returned 404'); - return NextResponse.json({ - error: 'ESPN credentials not found (credential check). Please add your ESPN credentials first.', - code: 'CREDENTIALS_MISSING_CHECK' - }, { status: 404 }); - } - - if (!credentialCheck.ok) { - const err = await credentialCheck.json().catch(() => ({})) as { error?: string }; - return NextResponse.json({ - error: err.error || 'Failed to verify ESPN credentials' - }, { status: credentialCheck.status }); - } - - const espnClientUrl = process.env.NEXT_PUBLIC_ESPN_CLIENT_URL; - const workerUrl = espnClientUrl; - console.log(`[auto-pull] ESPN client URL: ${espnClientUrl || 'NOT SET'}`); - console.log(`[auto-pull] Sport worker URL for ${sport}: ${workerUrl || 'NOT SET'}`); - - if (!workerUrl) { - return NextResponse.json({ - error: 'NEXT_PUBLIC_ESPN_CLIENT_URL is not configured' - }, { status: 500 }); - } - - try { - // Call the ESPN onboarding initialize endpoint - const sportWorkerFullUrl = `${workerUrl}/onboarding/initialize`; - console.log(`[auto-pull] Calling sport worker: ${sportWorkerFullUrl}`); - console.log(`[auto-pull] Sport worker request - userId: ${userId}, sport: ${sport}, leagueId: ${leagueId}, seasonYear: ${seasonYear}`); - console.log(`[auto-pull] Sport worker auth header: Bearer ${bearer ? `[${bearer.length} chars]` : 'MISSING'}`); - - const workerResponse = await fetch(sportWorkerFullUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) - }, - body: JSON.stringify({ - sport: sport, - leagueId: leagueId, - seasonYear: seasonYear - }) - }); - - console.log(`[auto-pull] Sport worker response status: ${workerResponse.status}`); - - if (!workerResponse.ok) { - const errorData = await workerResponse.json().catch(() => ({})) as { - code?: string; - error?: string; - }; - console.error('Sport worker error:', workerResponse.status, errorData); - - if (workerResponse.status === 404) { - if (errorData.code === 'CREDENTIALS_MISSING') { - console.error('[auto-pull] Sport worker returned CREDENTIALS_MISSING'); - return NextResponse.json({ - error: 'ESPN credentials not found (sport worker auth failed). Please add your ESPN credentials first.', - code: 'CREDENTIALS_MISSING_WORKER' - }, { status: 404 }); - } - - if (errorData.code === 'LEAGUES_MISSING') { - return NextResponse.json({ - error: `No ${sport} leagues found. Please add ${sport} leagues first.`, - code: 'LEAGUES_MISSING' - }, { status: 404 }); - } - - return NextResponse.json({ - error: errorData.error || 'Required data not found' - }, { status: 404 }); - } - - return NextResponse.json({ - error: errorData.error || 'Failed to retrieve league information' - }, { status: workerResponse.status }); - } - - const workerData = await workerResponse.json() as { - success?: boolean; - error?: string; - leagues?: Array<{ - leagueId: string; - sport: string; - teamId?: string; - leagueName?: string; - seasonYear?: number; - gameId?: string; - standings?: any[]; - teams?: any[]; - success?: boolean; - error?: string; - }>; - }; - - if (!workerData.success) { - return NextResponse.json({ - error: workerData.error || 'Failed to retrieve league information' - }, { status: 400 }); - } - - // Transform sport worker response into expected format for the frontend - // Sport worker now returns info for the specific requested league - const targetLeague = workerData.leagues?.[0]; - if (!targetLeague) { - return NextResponse.json({ - error: 'No league data returned from sport worker' - }, { status: 404 }); - } - - // Check if the league itself has an error (e.g., ESPN returned 404 for this season) - if (targetLeague.success === false && targetLeague.error) { - return NextResponse.json({ - error: targetLeague.error - }, { status: 404 }); - } - - const leagueInfo: EspnLeagueInfo = { - leagueId: targetLeague.leagueId, - leagueName: targetLeague.leagueName || `${sport} League ${targetLeague.leagueId}`, - sport: sport as SportName, - seasonYear: targetLeague.seasonYear || new Date().getFullYear(), - gameId: targetLeague.gameId || (sport === 'baseball' ? 'flb' : 'ffl'), - standings: targetLeague.standings || [], - teams: targetLeague.teams || [] - }; - - // Validate that we got meaningful data - if (!leagueInfo.teams || leagueInfo.teams.length === 0) { - // Provide helpful message about season-specific issues - const suggestedYear = leagueInfo.seasonYear === new Date().getFullYear() - ? leagueInfo.seasonYear - 1 - : leagueInfo.seasonYear; - return NextResponse.json({ - error: `No teams found for ${sport} league ${leagueId} in season ${leagueInfo.seasonYear}. ` + - `This league may not exist for this season. Try season ${suggestedYear} instead.` - }, { status: 404 }); - } - - const response: AutoPullResponse = { - success: true, - leagueInfo - }; - - return NextResponse.json(response); - - } catch (workerError) { - console.error('Sport worker integration error:', workerError); - return NextResponse.json({ - error: 'Failed to connect to league data service' - }, { status: 502 }); - } - + return NextResponse.json(result.body, { status: result.status }); } catch (error) { console.error('Auto-pull API error:', error); return NextResponse.json( - { error: 'Failed to auto-pull league information' }, + { error: 'Failed to auto-pull league information' }, { status: 500 } ); } diff --git a/web/app/api/espn/discover-seasons/route.ts b/web/app/api/espn/discover-seasons/route.ts index 53b0e748..e872258c 100644 --- a/web/app/api/espn/discover-seasons/route.ts +++ b/web/app/api/espn/discover-seasons/route.ts @@ -1,17 +1,10 @@ -/** - * ESPN Discover Seasons API Route - * --------------------------------------------------------------------------- - * Proxies to sport workers' /onboarding/discover-seasons endpoints. - * Probes ESPN API for all historical seasons of a league and auto-saves them. - */ - import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; +import { runEspnDiscoverSeasons } from '@/lib/server/espn-onboarding'; export async function POST(request: NextRequest) { try { const { userId, getToken } = await auth(); - if (!userId) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } @@ -19,56 +12,27 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { sport, leagueId } = body as { sport?: string; leagueId?: string }; - if (!sport || !leagueId) { - return NextResponse.json({ - error: 'Missing required fields: sport and leagueId' - }, { status: 400 }); - } - if (sport !== 'baseball' && sport !== 'football') { return NextResponse.json({ - error: 'Sport must be baseball or football' + error: 'Sport must be baseball or football', }, { status: 400 }); } const bearer = await getToken?.(); if (!bearer) { return NextResponse.json({ - error: 'Authentication token unavailable' + error: 'Authentication token unavailable', }, { status: 401 }); } - const workerUrl = process.env.NEXT_PUBLIC_ESPN_CLIENT_URL; - - if (!workerUrl) { - return NextResponse.json({ - error: 'NEXT_PUBLIC_ESPN_CLIENT_URL is not configured' - }, { status: 500 }); - } - - console.log(`[discover-seasons] Calling ${sport} worker: ${workerUrl}/onboarding/discover-seasons`); - - try { - const response = await fetch(`${workerUrl}/onboarding/discover-seasons`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearer}` - }, - body: JSON.stringify({ leagueId, sport }) - }); - - const data = await response.json(); - console.log(`[discover-seasons] Worker response status: ${response.status}`); - - return NextResponse.json(data, { status: response.status }); - } catch (workerError) { - console.error('Worker connection error:', workerError); - return NextResponse.json({ - error: 'Failed to connect to league discovery service' - }, { status: 502 }); - } + const result = await runEspnDiscoverSeasons({ + sport, + leagueId, + authHeader: `Bearer ${bearer}`, + correlationId: request.headers.get('X-Correlation-ID') || undefined, + }); + return NextResponse.json(result.body, { status: result.status }); } catch (error) { console.error('Discover seasons API error:', error); return NextResponse.json( diff --git a/web/lib/server/espn-onboarding.ts b/web/lib/server/espn-onboarding.ts new file mode 100644 index 00000000..de5eebbe --- /dev/null +++ b/web/lib/server/espn-onboarding.ts @@ -0,0 +1,667 @@ +import type { AutoPullResponse, EspnLeagueInfo, SportName } from '@/lib/espn-types'; + +interface EspnCredentials { + swid: string; + s2: string; + email?: string; +} + +interface LeagueConfig { + leagueId: string; + sport: string; + teamId?: string; + seasonYear?: number; +} + +interface DiscoveredSeason { + seasonYear: number; + leagueName: string; + teamCount: number; + teamId?: string; + teamName?: string; +} + +interface EspnApiTeam { + id?: number; + name?: string; + location?: string; + nickname?: string; + playoffSeed?: number; + rank?: number; + owners?: Array<{ displayName?: string; firstName?: string }>; + record?: { overall?: { wins?: number; losses?: number; ties?: number } }; +} + +interface EspnLeagueResponse { + seasonId?: number; + settings?: { name?: string }; + teams?: EspnApiTeam[]; +} + +interface BasicLeagueInfoResponse { + success: boolean; + leagueName?: string; + seasonYear?: number; + standings?: Array<{ + teamId: string; + teamName: string; + wins: number; + losses: number; + ties: number; + winPercentage: number; + rank: number; + playoffSeed?: number; + }>; + teams?: Array<{ + teamId: string; + teamName: string; + ownerName?: string; + }>; + error?: string; + httpStatus?: number; +} + +interface RouteResult { + status: number; + body: T; +} + +const INTERNAL_SERVICE_TOKEN_HEADER = 'X-Flaim-Internal-Token'; +const ESPN_BASE_URL = 'https://lm-api-reads.fantasy.espn.com/apis/v3'; + +const ESPN_GAME_IDS: Record = { + football: 'ffl', + baseball: 'flb', + basketball: 'fba', + hockey: 'fhl', +}; + +function getAuthWorkerUrl(): string { + const authWorkerUrl = process.env.NEXT_PUBLIC_AUTH_WORKER_URL; + if (!authWorkerUrl) { + throw new Error('NEXT_PUBLIC_AUTH_WORKER_URL is not configured'); + } + return authWorkerUrl; +} + +function getInternalServiceToken(): string { + const internalServiceToken = process.env.INTERNAL_SERVICE_TOKEN; + if (!internalServiceToken) { + throw new Error('INTERNAL_SERVICE_TOKEN is not configured'); + } + return internalServiceToken; +} + +function buildAuthHeaders( + authHeader?: string | null, + correlationId?: string, + includeJson = false, + includeInternalServiceToken = false +): Headers { + const headers = new Headers(); + if (authHeader) { + headers.set('Authorization', authHeader); + } + if (includeJson) { + headers.set('Content-Type', 'application/json'); + } + if (correlationId) { + headers.set('X-Correlation-ID', correlationId); + } + if (includeInternalServiceToken) { + headers.set(INTERNAL_SERVICE_TOKEN_HEADER, getInternalServiceToken()); + } + return headers; +} + +function normalizeSport(input?: string): SportName | null { + if (!input) return null; + const normalized = input.toLowerCase(); + if (normalized === 'football' || normalized === 'baseball' || normalized === 'basketball' || normalized === 'hockey') { + return normalized as SportName; + } + return null; +} + +function toEspnSeasonYear(canonicalYear: number, sport: string): number { + if (sport === 'basketball' || sport === 'hockey') { + return canonicalYear + 1; + } + return canonicalYear; +} + +async function fetchEspnCredentials( + authHeader: string, + correlationId?: string +): Promise { + const response = await fetch(`${getAuthWorkerUrl()}/internal/credentials/espn/raw`, { + method: 'GET', + headers: buildAuthHeaders(authHeader, correlationId, false, true), + cache: 'no-store', + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) as { error?: string; message?: string }; + throw new Error(errorData.error || errorData.message || response.statusText); + } + + const data = await response.json() as { success?: boolean; credentials?: EspnCredentials }; + if (!data.success || !data.credentials) { + throw new Error('Invalid credentials response from auth-worker'); + } + + return data.credentials; +} + +async function getUserLeagues( + authHeader: string, + correlationId?: string +): Promise { + const response = await fetch(`${getAuthWorkerUrl()}/leagues`, { + method: 'GET', + headers: buildAuthHeaders(authHeader, correlationId, true), + cache: 'no-store', + }); + + if (response.status === 404) { + return []; + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) as { error?: string }; + throw new Error(`Auth-worker error: ${errorData.error || response.statusText}`); + } + + const data = await response.json().catch(() => null) as { success?: boolean; leagues?: LeagueConfig[] } | null; + if (!data?.success) { + return []; + } + return data.leagues || []; +} + +async function addLeague( + authHeader: string, + league: Record, + correlationId?: string +): Promise { + return fetch(`${getAuthWorkerUrl()}/leagues/add`, { + method: 'POST', + headers: buildAuthHeaders(authHeader, correlationId, true), + body: JSON.stringify(league), + cache: 'no-store', + }); +} + +async function patchLeagueTeam( + authHeader: string, + leagueId: string, + body: Record, + correlationId?: string +): Promise { + return fetch(`${getAuthWorkerUrl()}/leagues/${leagueId}/team`, { + method: 'PATCH', + headers: buildAuthHeaders(authHeader, correlationId, true), + body: JSON.stringify(body), + cache: 'no-store', + }); +} + +async function espnFetch( + path: string, + gameId: string, + credentials: EspnCredentials, + timeout = 7000, + additionalHeaders: Record = {} +): Promise { + const url = `${ESPN_BASE_URL}/games/${gameId}${path}`; + const headers: Record = { + 'User-Agent': 'flaim-onboarding-autopull/1.0', + 'Accept': 'application/json', + 'X-Fantasy-Source': 'kona', + 'X-Fantasy-Platform': 'kona-web-2.0.0', + Cookie: `SWID=${credentials.swid}; espn_s2=${credentials.s2}`, + ...additionalHeaders, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + cache: 'no-store', + }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + +async function getBasicLeagueInfo( + leagueId: string, + sport: SportName, + credentials: EspnCredentials, + seasonYear?: number +): Promise { + try { + const requestedSeasonYear = seasonYear || new Date().getFullYear(); + const espnSeasonYear = toEspnSeasonYear(requestedSeasonYear, sport); + const gameId = ESPN_GAME_IDS[sport]; + const apiPath = `/seasons/${espnSeasonYear}/segments/0/leagues/${leagueId}?view=mStandings&view=mTeam&view=mSettings`; + + let response: Response; + try { + response = await espnFetch(apiPath, gameId, credentials); + } catch (fetchError) { + if (fetchError instanceof Error && fetchError.name === 'AbortError') { + return { success: false, error: 'ESPN API request timed out - try again', httpStatus: 504 }; + } + throw fetchError; + } + + if (response.status === 401 || response.status === 403) { + return { + success: false, + error: 'ESPN authentication failed - please verify your cookies are current and valid', + httpStatus: response.status, + }; + } + + if (response.status === 404) { + return { + success: false, + error: 'League not found - please check your league ID and sport selection', + httpStatus: 404, + }; + } + + if (response.status === 429) { + return { + success: false, + error: 'ESPN API rate limited - try again later', + httpStatus: 429, + }; + } + + if (!response.ok) { + return { + success: false, + error: `ESPN API error: ${response.status} ${response.statusText}`, + httpStatus: response.status, + }; + } + + const responseText = await response.text(); + let data: EspnLeagueResponse; + try { + data = JSON.parse(responseText) as EspnLeagueResponse; + } catch { + if (responseText.includes(' ({ + teamId: team.id?.toString() || '', + teamName: team.location && team.nickname + ? `${team.location} ${team.nickname}` + : team.name || `Team ${team.id}`, + ownerName: team.owners?.[0]?.displayName || team.owners?.[0]?.firstName || undefined, + })); + + const standings = (data.teams || []).map((team) => { + const wins = team.record?.overall?.wins || 0; + const losses = team.record?.overall?.losses || 0; + const ties = team.record?.overall?.ties || 0; + const totalGames = wins + losses + ties; + const winPercentage = totalGames > 0 ? wins / totalGames : 0; + + return { + teamId: team.id?.toString() || '', + teamName: team.location && team.nickname + ? `${team.location} ${team.nickname}` + : team.name || `Team ${team.id}`, + wins, + losses, + ties, + winPercentage: Math.round(winPercentage * 1000) / 1000, + rank: team.playoffSeed || team.rank || 0, + playoffSeed: team.playoffSeed || undefined, + }; + }).sort((a, b) => { + if (b.winPercentage !== a.winPercentage) { + return b.winPercentage - a.winPercentage; + } + return b.wins - a.wins; + }).map((team, index) => ({ + ...team, + rank: index + 1, + })); + + return { + success: true, + leagueName, + seasonYear: returnedSeasonYear, + standings, + teams, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + httpStatus: 500, + }; + } +} + +export async function runEspnAutoPull(params: { + sport?: string; + leagueId?: string; + seasonYear?: number; + authHeader: string; + correlationId?: string; +}): Promise> { + const { sport, leagueId, seasonYear, authHeader, correlationId } = params; + + if (!sport || !leagueId) { + return { status: 400, body: { error: 'Missing required fields: sport and leagueId' } }; + } + + const targetSport = normalizeSport(sport); + if (!targetSport) { + return { status: 400, body: { error: 'Unsupported sport', code: 'SPORT_NOT_SUPPORTED' } }; + } + + const credentials = await fetchEspnCredentials(authHeader, correlationId); + if (!credentials) { + return { + status: 404, + body: { + error: 'ESPN credentials not found. Please add your ESPN credentials first.', + code: 'CREDENTIALS_MISSING', + }, + }; + } + + const leagueInfo = await getBasicLeagueInfo(leagueId, targetSport, credentials, seasonYear); + if (!leagueInfo.success) { + return { + status: leagueInfo.httpStatus || 502, + body: { error: leagueInfo.error || 'Failed to retrieve league information' }, + }; + } + + const responseLeagueInfo: EspnLeagueInfo = { + leagueId, + leagueName: leagueInfo.leagueName || `${targetSport} League ${leagueId}`, + sport: targetSport, + seasonYear: leagueInfo.seasonYear || seasonYear || new Date().getFullYear(), + gameId: ESPN_GAME_IDS[targetSport], + standings: leagueInfo.standings || [], + teams: leagueInfo.teams || [], + }; + + if (!responseLeagueInfo.teams.length) { + const suggestedYear = responseLeagueInfo.seasonYear === new Date().getFullYear() + ? responseLeagueInfo.seasonYear - 1 + : responseLeagueInfo.seasonYear; + return { + status: 404, + body: { + error: `No teams found for ${targetSport} league ${leagueId} in season ${responseLeagueInfo.seasonYear}. Try season ${suggestedYear} instead.`, + }, + }; + } + + return { + status: 200, + body: { + success: true, + leagueInfo: responseLeagueInfo, + }, + }; +} + +export async function runEspnDiscoverSeasons(params: { + sport?: string; + leagueId?: string; + authHeader: string; + correlationId?: string; +}): Promise>> { + const { sport, leagueId, authHeader, correlationId } = params; + + if (!leagueId) { + return { status: 400, body: { error: 'leagueId is required' } }; + } + + const targetSport = normalizeSport(sport); + if (!targetSport) { + return { status: 400, body: { error: 'Unsupported sport', code: 'SPORT_NOT_SUPPORTED' } }; + } + + const credentials = await fetchEspnCredentials(authHeader, correlationId); + if (!credentials) { + return { status: 404, body: { error: 'ESPN credentials not found', code: 'CREDENTIALS_MISSING' } }; + } + + const leagues = await getUserLeagues(authHeader, correlationId); + const matchingLeagues = leagues.filter((league) => league.leagueId === leagueId && league.sport === targetSport); + const baseTeamId = matchingLeagues.find((league) => league.teamId)?.teamId; + + if (!baseTeamId) { + return { status: 400, body: { error: 'Team selection required before discovering seasons', code: 'TEAM_ID_MISSING' } }; + } + + const existingSeasons = new Set( + matchingLeagues + .map((league) => league.seasonYear) + .filter((value): value is number => typeof value === 'number') + ); + + const MIN_YEAR = 2000; + const MAX_CONSECUTIVE_MISSES = 2; + const PROBE_DELAY_MS = 200; + const currentYear = new Date().getFullYear(); + const discovered: DiscoveredSeason[] = []; + let consecutiveMisses = 0; + let skippedCount = 0; + let rateLimited = false; + let limitExceeded = false; + let minYearReached = false; + + for (let year = currentYear; year >= MIN_YEAR; year -= 1) { + if (year === MIN_YEAR) { + minYearReached = true; + } + + if (existingSeasons.has(year)) { + skippedCount += 1; + continue; + } + + const mustProbe = year >= currentYear - 1; + if (!mustProbe && consecutiveMisses >= MAX_CONSECUTIVE_MISSES) { + break; + } + + if (discovered.length > 0 || consecutiveMisses > 0) { + await new Promise((resolve) => setTimeout(resolve, PROBE_DELAY_MS)); + } + + const info = await getBasicLeagueInfo(leagueId, targetSport, credentials, year); + + if (info.success && (!info.teams || info.teams.length === 0)) { + consecutiveMisses += 1; + continue; + } + + if (info.success) { + const matchedTeam = info.teams?.find((team) => team.teamId === baseTeamId); + const seasonTeamName = matchedTeam?.teamName; + discovered.push({ + seasonYear: year, + leagueName: info.leagueName || `${targetSport} League ${leagueId}`, + teamCount: info.teams?.length || 0, + teamId: baseTeamId, + teamName: seasonTeamName, + }); + consecutiveMisses = 0; + + try { + const addResponse = await addLeague(authHeader, { + leagueId, + sport: targetSport, + seasonYear: year, + leagueName: info.leagueName, + teamId: baseTeamId, + teamName: seasonTeamName, + }, correlationId); + + if (addResponse.status === 409) { + await patchLeagueTeam(authHeader, leagueId, { + teamId: baseTeamId, + sport: targetSport, + teamName: seasonTeamName, + leagueName: info.leagueName, + seasonYear: year, + }, correlationId); + } else if (addResponse.status === 400) { + const addData = await addResponse.json().catch(() => ({})) as { code?: string }; + if (addData.code === 'LIMIT_EXCEEDED') { + limitExceeded = true; + break; + } + } + } catch { + // Ignore save errors and continue discovery. + } + + continue; + } + + if (info.httpStatus === 404) { + consecutiveMisses += 1; + continue; + } + + if (info.httpStatus === 429) { + rateLimited = true; + break; + } + + if (info.httpStatus === 401 || info.httpStatus === 403) { + const hasKnownSeason = discovered.length > 0 || existingSeasons.size > 0; + if (hasKnownSeason) { + consecutiveMisses += 1; + continue; + } + return { status: 401, body: { error: 'ESPN credentials expired or invalid', code: 'AUTH_FAILED' } }; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + const retry = await getBasicLeagueInfo(leagueId, targetSport, credentials, year); + + if (retry.success && (!retry.teams || retry.teams.length === 0)) { + consecutiveMisses += 1; + continue; + } + + if (retry.success) { + const matchedTeam = retry.teams?.find((team) => team.teamId === baseTeamId); + const seasonTeamName = matchedTeam?.teamName; + discovered.push({ + seasonYear: year, + leagueName: retry.leagueName || `${targetSport} League ${leagueId}`, + teamCount: retry.teams?.length || 0, + teamId: baseTeamId, + teamName: seasonTeamName, + }); + consecutiveMisses = 0; + + try { + const addResponse = await addLeague(authHeader, { + leagueId, + sport: targetSport, + seasonYear: year, + leagueName: retry.leagueName, + teamId: baseTeamId, + teamName: seasonTeamName, + }, correlationId); + + if (addResponse.status === 409) { + await patchLeagueTeam(authHeader, leagueId, { + teamId: baseTeamId, + sport: targetSport, + teamName: seasonTeamName, + leagueName: retry.leagueName, + seasonYear: year, + }, correlationId); + } else if (addResponse.status === 400) { + const addData = await addResponse.json().catch(() => ({})) as { code?: string }; + if (addData.code === 'LIMIT_EXCEEDED') { + limitExceeded = true; + break; + } + } + } catch { + // Ignore save errors and continue discovery. + } + + continue; + } + + if (retry.httpStatus === 404) { + consecutiveMisses += 1; + continue; + } + + if (retry.httpStatus === 401 || retry.httpStatus === 403) { + const hasKnownSeason = discovered.length > 0 || existingSeasons.size > 0; + if (hasKnownSeason) { + consecutiveMisses += 1; + continue; + } + return { status: 401, body: { error: 'ESPN credentials expired or invalid', code: 'AUTH_FAILED' } }; + } + + return { + status: 502, + body: { error: `ESPN API error: ${retry.error}`, code: 'ESPN_ERROR' }, + }; + } + + return { + status: 200, + body: { + success: true, + leagueId, + sport: targetSport, + startYear: currentYear, + minYearReached, + rateLimited, + limitExceeded, + discovered, + skipped: skippedCount, + ...(limitExceeded ? { error: 'League limit reached - some seasons may not have been saved' } : {}), + }, + }; +} diff --git a/workers/auth-worker/README.md b/workers/auth-worker/README.md index 58b78fb4..75444e3f 100644 --- a/workers/auth-worker/README.md +++ b/workers/auth-worker/README.md @@ -48,11 +48,12 @@ These endpoints manage the OAuth 2.0 client flow with Yahoo Fantasy. |----------|------|---------| | `GET /connect/yahoo/authorize` | Clerk JWT | Start Yahoo OAuth flow | | `GET /connect/yahoo/callback` | None (state param) | Handle Yahoo redirect | -| `GET /connect/yahoo/credentials` | Clerk JWT / OAuth | Get Yahoo tokens (auto-refreshes) | +| `GET /internal/connect/yahoo/credentials` | Internal + Clerk JWT / OAuth / Eval key | Get Yahoo tokens (auto-refreshes) for internal workers | | `GET /connect/yahoo/status` | Clerk JWT | Check Yahoo connection status | | `DELETE /connect/yahoo/disconnect` | Clerk JWT | Remove Yahoo connection | | `POST /connect/yahoo/discover` | Clerk JWT | Discover Yahoo leagues | -| `GET /leagues/yahoo` | Clerk JWT / OAuth | Get stored Yahoo leagues | +| `GET /leagues/yahoo` | Clerk JWT | Get stored Yahoo leagues | +| `GET /internal/leagues/yahoo` | Internal + Clerk JWT / OAuth / Eval key | Get stored Yahoo leagues for internal workers | | `DELETE /leagues/yahoo/:id` | Clerk JWT | Delete a Yahoo league | ### Extension APIs @@ -69,9 +70,11 @@ These endpoints manage the OAuth 2.0 client flow with Yahoo Fantasy. | Endpoint | Auth | Purpose | |----------|------|---------| | `POST /credentials/espn` | Clerk JWT | Store ESPN credentials | -| `GET /credentials/espn` | Clerk JWT / OAuth | Get ESPN credential metadata (or raw with `?raw=true`) | +| `GET /credentials/espn` | Clerk JWT | Get ESPN credential metadata | +| `GET /internal/credentials/espn/raw` | Internal + Clerk JWT / OAuth / Eval key | Get raw ESPN credentials for internal workers | | `DELETE /credentials/espn` | Clerk JWT | Delete ESPN credentials | -| `GET /leagues` | Clerk JWT / OAuth | Get ESPN leagues | +| `GET /leagues` | Clerk JWT | Get ESPN leagues | +| `GET /internal/leagues` | Internal + Clerk JWT / OAuth / Eval key | Get ESPN leagues for internal workers | | `POST /leagues` | Clerk JWT | Store ESPN leagues | | `POST /leagues/add` | Clerk JWT | Add a single ESPN league (season-aware) | | `DELETE /leagues` | Clerk JWT | Remove all seasons for a league | @@ -83,18 +86,19 @@ These endpoints manage the OAuth 2.0 client flow with Yahoo Fantasy. | Endpoint | Auth | Purpose | |----------|------|---------| -| `GET /user/preferences` | Clerk JWT / OAuth | Get user preferences | +| `GET /user/preferences` | Clerk JWT | Get user preferences | +| `GET /internal/user/preferences` | Internal + Clerk JWT / OAuth / Eval key | Get user preferences for internal workers | | `POST /user/preferences/default-sport` | Clerk JWT | Set default sport | ## Authentication -Three auth mechanisms, depending on caller: +Three user auth mechanisms, depending on caller: - **Clerk JWT** — used by the web app, extension, and frontend OAuth consent flow. - **OAuth access token** — used by AI clients (Claude, ChatGPT, Gemini) after completing the OAuth 2.1 flow. - **Eval API key** — static key for eval/CI/agent use. Bypasses OAuth browser flow entirely. -All are validated in `getVerifiedUserId()`; the resolved `userId` is the same regardless of method. +Public app routes are Clerk-only. Internal helper routes additionally require `X-Flaim-Internal-Token` and can resolve Clerk, OAuth, or eval auth to a user ID. ### Eval API Key @@ -106,13 +110,13 @@ A static Bearer token that resolves to a specific Clerk user ID with `mcp:read` - **Constant-time comparison:** Uses SHA-256 digest comparison to prevent timing attacks. - **Both secrets required:** `EVAL_API_KEY` + `EVAL_USER_ID` must both be set. If only `EVAL_API_KEY` is set, API key auth is skipped (logged) and falls through to OAuth. -**Allowlisted routes (MCP-read path only):** -- `GET /auth/introspect` -- `GET /credentials/espn?raw=true` -- `GET /connect/yahoo/credentials` -- `GET /leagues` -- `GET /leagues/yahoo` -- `GET /user/preferences` +**Allowlisted internal routes (MCP-read path only):** +- `GET /auth/internal/introspect` +- `GET /auth/internal/credentials/espn/raw` +- `GET /auth/internal/connect/yahoo/credentials` +- `GET /auth/internal/leagues` +- `GET /auth/internal/leagues/yahoo` +- `GET /auth/internal/user/preferences` **Current mapping:** `EVAL_USER_ID` → `user_36UBCM4x2hK1aJYY1F7iV1svNw6` (test email on Clerk prod). diff --git a/workers/auth-worker/src/__tests__/eval-api-key.test.ts b/workers/auth-worker/src/__tests__/eval-api-key.test.ts index 48aa9139..da02c550 100644 --- a/workers/auth-worker/src/__tests__/eval-api-key.test.ts +++ b/workers/auth-worker/src/__tests__/eval-api-key.test.ts @@ -6,6 +6,7 @@ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test') const EVAL_API_KEY = 'flaim_eval_abc123testkey'; const EVAL_USER_ID = 'user_eval_test_12345'; +const INTERNAL_SERVICE_TOKEN = 'internal-service-secret'; const mockRateLimiter = { limit: async () => ({ success: true }) }; @@ -16,6 +17,7 @@ const baseEnv = { ENVIRONMENT: 'test', EVAL_API_KEY, EVAL_USER_ID, + INTERNAL_SERVICE_TOKEN, TOKEN_RATE_LIMITER: mockRateLimiter, CREDENTIALS_RATE_LIMITER: mockRateLimiter, }; @@ -28,6 +30,13 @@ function bearerHeaders(token: string): HeadersInit { return { Authorization: `Bearer ${token}` }; } +function internalHeaders(token: string): HeadersInit { + return { + ...bearerHeaders(token), + 'X-Flaim-Internal-Token': INTERNAL_SERVICE_TOKEN, + }; +} + async function appFetch(req: Request, env = baseEnv): Promise { return app.fetch(req, env); } @@ -113,11 +122,11 @@ describe('eval API key auth', () => { // Positive — MCP-read path works // ========================================================================= - it('GET /auth/introspect with valid API key returns valid', async () => { + it('GET /auth/internal/introspect with valid API key returns valid', async () => { const res = await appFetch( - makeRequest('/auth/introspect', { + makeRequest('/auth/internal/introspect', { headers: { - ...bearerHeaders(EVAL_API_KEY), + ...internalHeaders(EVAL_API_KEY), 'X-Flaim-Expected-Resource': 'https://api.flaim.app/mcp', }, }) @@ -129,10 +138,10 @@ describe('eval API key auth', () => { expect(body.scope).toBe('mcp:read'); }); - it('GET /credentials/espn?raw=true with valid API key returns credentials', async () => { + it('GET /auth/internal/credentials/espn/raw with valid API key returns credentials', async () => { const res = await appFetch( - makeRequest('/auth/credentials/espn?raw=true', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/credentials/espn/raw', { + headers: internalHeaders(EVAL_API_KEY), }) ); expect(res.status).toBe(200); @@ -141,10 +150,10 @@ describe('eval API key auth', () => { expect(body.credentials).toBeTruthy(); }); - it('GET /auth/leagues with valid API key returns leagues', async () => { + it('GET /auth/internal/leagues with valid API key returns leagues', async () => { const res = await appFetch( - makeRequest('/auth/leagues', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/leagues', { + headers: internalHeaders(EVAL_API_KEY), }) ); expect(res.status).toBe(200); @@ -152,10 +161,10 @@ describe('eval API key auth', () => { expect(body.success).toBe(true); }); - it('GET /auth/leagues/yahoo with valid API key returns leagues', async () => { + it('GET /auth/internal/leagues/yahoo with valid API key returns leagues', async () => { const res = await appFetch( - makeRequest('/auth/leagues/yahoo', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/leagues/yahoo', { + headers: internalHeaders(EVAL_API_KEY), }) ); expect(res.status).toBe(200); @@ -163,19 +172,19 @@ describe('eval API key auth', () => { expect(body.leagues).toEqual([]); }); - it('GET /auth/connect/yahoo/credentials with valid API key succeeds', async () => { + it('GET /auth/internal/connect/yahoo/credentials with valid API key succeeds', async () => { const res = await appFetch( - makeRequest('/auth/connect/yahoo/credentials', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/connect/yahoo/credentials', { + headers: internalHeaders(EVAL_API_KEY), }) ); expect(res.status).toBe(200); }); - it('GET /auth/user/preferences with valid API key returns preferences', async () => { + it('GET /auth/internal/user/preferences with valid API key returns preferences', async () => { const res = await appFetch( - makeRequest('/auth/user/preferences', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/user/preferences', { + headers: internalHeaders(EVAL_API_KEY), }) ); expect(res.status).toBe(200); @@ -267,10 +276,10 @@ describe('eval API key auth', () => { // Edge cases // ========================================================================= - it('wrong API key falls through to OAuth and returns 401', async () => { + it('wrong API key with internal token falls through to OAuth and returns 401', async () => { const res = await appFetch( - makeRequest('/auth/introspect', { - headers: bearerHeaders('wrong-key'), + makeRequest('/auth/internal/introspect', { + headers: internalHeaders('wrong-key'), }) ); expect(res.status).toBe(401); @@ -281,8 +290,8 @@ describe('eval API key auth', () => { const envMissingUserId = { ...baseEnv, EVAL_USER_ID: undefined }; const res = await appFetch( - makeRequest('/auth/introspect', { - headers: bearerHeaders(EVAL_API_KEY), + makeRequest('/auth/internal/introspect', { + headers: internalHeaders(EVAL_API_KEY), }), envMissingUserId as any ); @@ -301,8 +310,8 @@ describe('eval API key auth', () => { }; const res = await appFetch( - makeRequest('/auth/introspect', { - headers: bearerHeaders('some-token'), + makeRequest('/auth/internal/introspect', { + headers: internalHeaders('some-token'), }), envNoSecrets as any ); @@ -311,9 +320,9 @@ describe('eval API key auth', () => { it('valid API key with wrong expectedResource on introspect is rejected', async () => { const res = await appFetch( - makeRequest('/auth/introspect', { + makeRequest('/auth/internal/introspect', { headers: { - ...bearerHeaders(EVAL_API_KEY), + ...internalHeaders(EVAL_API_KEY), 'X-Flaim-Expected-Resource': 'https://evil.example.com/mcp', }, }) @@ -325,9 +334,9 @@ describe('eval API key auth', () => { it('valid API key with legacy fantasy/mcp resource passes', async () => { const res = await appFetch( - makeRequest('/auth/introspect', { + makeRequest('/auth/internal/introspect', { headers: { - ...bearerHeaders(EVAL_API_KEY), + ...internalHeaders(EVAL_API_KEY), 'X-Flaim-Expected-Resource': 'https://api.flaim.app/fantasy/mcp', }, }) @@ -341,8 +350,8 @@ describe('eval API key auth', () => { // Same length as EVAL_API_KEY but different content const wrongKey = 'flaim_eval_xyz789wrongkey'; const res = await appFetch( - makeRequest('/auth/introspect', { - headers: bearerHeaders(wrongKey), + makeRequest('/auth/internal/introspect', { + headers: internalHeaders(wrongKey), }) ); expect(res.status).toBe(401); @@ -362,8 +371,10 @@ describe('eval API key auth', () => { }); const res = await appFetch( - makeRequest('/auth/introspect', { - headers: bearerHeaders('valid-oauth-token-not-api-key'), + makeRequest('/auth/internal/introspect', { + headers: { + ...internalHeaders('valid-oauth-token-not-api-key'), + }, }) ); expect(res.status).toBe(200); @@ -372,7 +383,7 @@ describe('eval API key auth', () => { expect(body.userId).toBe('user_oauth_regular'); }); - it('GET /credentials/espn without ?raw=true rejects API key (no opt-in)', async () => { + it('GET /auth/credentials/espn without ?raw=true rejects API key (no opt-in)', async () => { const res = await appFetch( makeRequest('/auth/credentials/espn', { headers: bearerHeaders(EVAL_API_KEY), @@ -381,7 +392,7 @@ describe('eval API key auth', () => { expect(res.status).toBe(401); }); - it('GET /credentials/espn?forEdit=true rejects API key (no opt-in)', async () => { + it('GET /auth/credentials/espn?forEdit=true rejects API key (no opt-in)', async () => { const res = await appFetch( makeRequest('/auth/credentials/espn?forEdit=true', { headers: bearerHeaders(EVAL_API_KEY), @@ -389,4 +400,22 @@ describe('eval API key auth', () => { ); expect(res.status).toBe(401); }); + + it('internal helper route rejects requests missing internal token', async () => { + const res = await appFetch( + makeRequest('/auth/internal/credentials/espn/raw', { + headers: bearerHeaders(EVAL_API_KEY), + }) + ); + expect(res.status).toBe(403); + }); + + it('public Yahoo credentials route rejects eval API key', async () => { + const res = await appFetch( + makeRequest('/auth/connect/yahoo/status', { + headers: bearerHeaders(EVAL_API_KEY), + }) + ); + expect(res.status).toBe(401); + }); }); diff --git a/workers/auth-worker/src/index-hono.ts b/workers/auth-worker/src/index-hono.ts index ad34ffe5..e11ca97c 100644 --- a/workers/auth-worker/src/index-hono.ts +++ b/workers/auth-worker/src/index-hono.ts @@ -89,6 +89,7 @@ export interface Env { // Eval/CI API key auth (Cloudflare secrets) EVAL_API_KEY?: string; // Static API key for eval/CI EVAL_USER_ID?: string; // Clerk user ID that the API key resolves to + INTERNAL_SERVICE_TOKEN?: string; // Rate limiting (Cloudflare Workers native) TOKEN_RATE_LIMITER: RateLimit; CREDENTIALS_RATE_LIMITER: RateLimit; @@ -112,6 +113,7 @@ type JwtPayload = { sub?: string; iss?: string; exp?: number; [k: string]: unkno const EVAL_RUN_HEADER = 'X-Flaim-Eval-Run'; const EVAL_TRACE_HEADER = 'X-Flaim-Eval-Trace'; +const INTERNAL_SERVICE_TOKEN_HEADER = 'X-Flaim-Internal-Token'; const ALLOWED_ORIGINS = [ 'https://flaim-*.vercel.app', @@ -388,6 +390,51 @@ async function getVerifiedUserId( return { userId: null, error: 'Missing or invalid Authorization token' }; } +async function getClerkUserId(request: Request, env: Env): Promise { + const authz = request.headers.get('Authorization'); + try { + const userId = await verifyJwtAndGetUserId(authz, env); + if (userId) { + return { userId, authType: 'clerk' }; + } + } catch (error) { + debugLog(env, `⚠️ [auth-worker] Clerk-only auth failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + + return { userId: null, error: 'Clerk authentication required' }; +} + +async function requireInternalService(request: Request, env: Env): Promise { + if (!env.INTERNAL_SERVICE_TOKEN) { + return 'Internal service authentication is not configured'; + } + + const providedToken = request.headers.get(INTERNAL_SERVICE_TOKEN_HEADER); + if (!providedToken) { + return `Missing or invalid ${INTERNAL_SERVICE_TOKEN_HEADER}`; + } + + if (!(await constantTimeEqual(providedToken, env.INTERNAL_SERVICE_TOKEN))) { + return `Missing or invalid ${INTERNAL_SERVICE_TOKEN_HEADER}`; + } + + return null; +} + +async function getInternalUserId( + request: Request, + env: Env, + expectedResource?: string, + options?: { allowEvalApiKey?: boolean } +): Promise { + const internalError = await requireInternalService(request, env); + if (internalError) { + return { userId: null, error: internalError }; + } + + return getVerifiedUserId(request, env, expectedResource, options); +} + function maskUserId(userId: string): string { if (!userId || userId.length <= 8) return '***'; return `${userId.substring(0, 8)}...`; @@ -608,12 +655,12 @@ api.post('/revoke', (c) => { }); // Token introspection (internal — called by fantasy-mcp gateway via service binding) -api.get('/introspect', async (c) => { +api.get('/internal/introspect', async (c) => { const expectedResource = c.req.header('X-Flaim-Expected-Resource') || undefined; - const { userId, error: authError, scope } = await getVerifiedUserId(c.req.raw, c.env, expectedResource, { allowEvalApiKey: true }); + const { userId, error: authError, scope } = await getInternalUserId(c.req.raw, c.env, expectedResource, { allowEvalApiKey: true }); if (!userId) { - return c.json({ valid: false, error: authError || 'Invalid token' }, 401); + return c.json({ valid: false, error: authError || 'Invalid token' }, authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401); } return c.json({ valid: true, userId, scope: scope || 'mcp:read' }); @@ -625,7 +672,7 @@ api.get('/introspect', async (c) => { // Create authorization code (called by frontend after user consent) api.post('/oauth/code', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -637,7 +684,7 @@ api.post('/oauth/code', async (c) => { // Check connection status (called by frontend) api.get('/oauth/status', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -649,7 +696,7 @@ api.get('/oauth/status', async (c) => { // Revoke all tokens for user (called by frontend) api.post('/oauth/revoke-all', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -661,7 +708,7 @@ api.post('/oauth/revoke-all', async (c) => { // Revoke a single token by ID (called by frontend) api.post('/oauth/revoke', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -677,7 +724,7 @@ api.post('/oauth/revoke', async (c) => { // Sync ESPN credentials (requires Clerk JWT) api.post('/extension/sync', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -689,7 +736,7 @@ api.post('/extension/sync', async (c) => { // Get extension status (requires Clerk JWT) api.get('/extension/status', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -701,7 +748,7 @@ api.get('/extension/status', async (c) => { // Get extension connection for web UI (requires Clerk auth) api.get('/extension/connection', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -713,7 +760,7 @@ api.get('/extension/connection', async (c) => { // Discover and save leagues (requires Clerk JWT) api.post('/extension/discover', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -821,7 +868,7 @@ api.post('/extension/discover', async (c) => { // Redirect to Yahoo OAuth (requires Clerk JWT) api.get('/connect/yahoo/authorize', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -837,8 +884,8 @@ api.get('/connect/yahoo/callback', async (c) => { }); // Get Yahoo credentials (internal use - requires auth) -api.get('/connect/yahoo/credentials', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); +api.get('/internal/connect/yahoo/credentials', async (c) => { + const { userId, error: authError } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); if (!userId) { return c.json({ error: 'unauthorized', @@ -850,7 +897,7 @@ api.get('/connect/yahoo/credentials', async (c) => { // Check Yahoo connection status (requires Clerk JWT) api.get('/connect/yahoo/status', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -862,7 +909,7 @@ api.get('/connect/yahoo/status', async (c) => { // Disconnect Yahoo (requires Clerk JWT) api.delete('/connect/yahoo/disconnect', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -874,7 +921,7 @@ api.delete('/connect/yahoo/disconnect', async (c) => { // Discover Yahoo leagues (requires Clerk JWT) api.post('/connect/yahoo/discover', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -886,7 +933,7 @@ api.post('/connect/yahoo/discover', async (c) => { // List Yahoo leagues (requires auth) api.get('/leagues/yahoo', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -900,9 +947,25 @@ api.get('/leagues/yahoo', async (c) => { return c.json({ leagues }, 200); }); +api.get('/internal/leagues/yahoo', async (c) => { + const { userId, error: authError } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + if (!userId) { + const status = authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401; + return c.json({ + error: 'unauthorized', + error_description: authError || 'Authentication required', + }, status); + } + + const storage = YahooStorage.fromEnvironment(c.env); + const leagues = await storage.getYahooLeagues(userId); + + return c.json({ leagues }, 200); +}); + // Delete Yahoo league (requires auth) api.delete('/leagues/yahoo/:id', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -928,7 +991,7 @@ api.delete('/leagues/yahoo/:id', async (c) => { // Discover Sleeper leagues (requires Clerk JWT) api.post('/connect/sleeper/discover', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -940,7 +1003,7 @@ api.post('/connect/sleeper/discover', async (c) => { // Check Sleeper connection status (requires Clerk JWT) api.get('/connect/sleeper/status', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -952,7 +1015,7 @@ api.get('/connect/sleeper/status', async (c) => { // Disconnect Sleeper (requires Clerk JWT) api.delete('/connect/sleeper/disconnect', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -964,7 +1027,7 @@ api.delete('/connect/sleeper/disconnect', async (c) => { // List Sleeper leagues (requires auth) api.get('/leagues/sleeper', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -974,9 +1037,21 @@ api.get('/leagues/sleeper', async (c) => { return handleSleeperLeagues(c.env as SleeperConnectEnv, userId, getCorsHeaders(c.req.raw)); }); +api.get('/internal/leagues/sleeper', async (c) => { + const { userId, error: authError } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + if (!userId) { + const status = authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401; + return c.json({ + error: 'unauthorized', + error_description: authError || 'Authentication required', + }, status); + } + return handleSleeperLeagues(c.env as SleeperConnectEnv, userId, getCorsHeaders(c.req.raw)); +}); + // Delete Sleeper league (requires auth) api.delete('/leagues/sleeper/:id', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', @@ -994,7 +1069,7 @@ api.delete('/leagues/sleeper/:id', async (c) => { // Get user preferences api.get('/user/preferences', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', error_description: authError || 'Authentication required' }, 401); } @@ -1011,9 +1086,28 @@ api.get('/user/preferences', async (c) => { }); }); +api.get('/internal/user/preferences', async (c) => { + const { userId, error: authError } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + if (!userId) { + const status = authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401; + return c.json({ error: 'unauthorized', error_description: authError || 'Authentication required' }, status); + } + + const storage = EspnSupabaseStorage.fromEnvironment(c.env); + const preferences = await storage.getUserPreferences(userId); + + return c.json({ + defaultSport: preferences.defaultSport, + defaultFootball: preferences.defaultFootball, + defaultBaseball: preferences.defaultBaseball, + defaultBasketball: preferences.defaultBasketball, + defaultHockey: preferences.defaultHockey, + }); +}); + // Set default sport api.post('/user/preferences/default-sport', async (c) => { - const { userId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!userId) { return c.json({ error: 'unauthorized', error_description: authError || 'Authentication required' }, 401); } @@ -1052,13 +1146,25 @@ api.delete('/credentials/espn', async (c) => { return handleCredentialsEspn(c, 'DELETE'); }); +api.get('/internal/credentials/espn/raw', async (c) => { + const { userId, error: authError, authType } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + debugLog(c.env, `🔐 [auth-worker] /internal/credentials/espn/raw - Verified user: ${userId ? maskUserId(userId) : 'null'}, authType: ${authType || 'none'}, authError: ${authError || 'none'}`); + + if (!userId) { + const status = authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401; + return c.json({ + error: 'Authentication required', + message: authError || 'Missing or invalid Authorization token' + }, status); + } + + return getRawEspnCredentialsResponse(c.env, userId, getCorsHeaders(c.req.raw)); +}); + async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: string): Promise { const env = c.env; const url = new URL(c.req.url); - const corsHeaders = getCorsHeaders(c.req.raw); - - const isRawGet = method === 'GET' && url.searchParams.get('raw') === 'true'; - const { userId: clerkUserId, error: authError, authType } = await getVerifiedUserId(c.req.raw, env, undefined, { allowEvalApiKey: isRawGet }); + const { userId: clerkUserId, error: authError, authType } = await getClerkUserId(c.req.raw, env); debugLog(env, `🔐 [auth-worker] /credentials/espn - Verified user: ${clerkUserId ? maskUserId(clerkUserId) : 'null'}, authType: ${authType || 'none'}, authError: ${authError || 'none'}`); if (!clerkUserId) { @@ -1096,39 +1202,10 @@ async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: stri const getRawCredentials = url.searchParams.get('raw') === 'true'; if (getRawCredentials) { - console.log(`🔍 [auth-worker] GET raw credentials for user: ${maskUserId(clerkUserId)}`); - - const { success } = await env.CREDENTIALS_RATE_LIMITER.limit({ key: clerkUserId }); - if (!success) { - console.log(`⚠️ [auth-worker] Rate limit exceeded for user: ${maskUserId(clerkUserId)}`); - return new Response(JSON.stringify({ - error: 'Rate limit exceeded', - message: 'Too many requests. Please try again later.', - }), { - status: 429, - headers: { 'Content-Type': 'application/json', ...corsHeaders } - }); - } - - const credentials = await storage.getCredentials(clerkUserId); - - if (!credentials) { - return new Response(JSON.stringify({ - error: 'Credentials not found', - message: 'No ESPN credentials found for user. Add your ESPN credentials at /settings/espn' - }), { - status: 404, - headers: { 'Content-Type': 'application/json', ...corsHeaders } - }); - } - - return new Response(JSON.stringify({ - success: true, - platform: 'espn', - credentials - }), { - headers: { 'Content-Type': 'application/json', ...corsHeaders } - }); + return c.json({ + error: 'forbidden', + message: 'Raw credential reads are available only via /internal/credentials/espn/raw', + }, 403); } // Check if this is an edit request @@ -1181,6 +1258,43 @@ async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: stri return c.json({ error: 'Method not allowed' }, 405); } +async function getRawEspnCredentialsResponse(env: Env, clerkUserId: string, corsHeaders: Record): Promise { + console.log(`🔍 [auth-worker] GET raw credentials for user: ${maskUserId(clerkUserId)}`); + + const { success } = await env.CREDENTIALS_RATE_LIMITER.limit({ key: clerkUserId }); + if (!success) { + console.log(`⚠️ [auth-worker] Rate limit exceeded for user: ${maskUserId(clerkUserId)}`); + return new Response(JSON.stringify({ + error: 'Rate limit exceeded', + message: 'Too many requests. Please try again later.', + }), { + status: 429, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + const storage = EspnSupabaseStorage.fromEnvironment(env); + const credentials = await storage.getCredentials(clerkUserId); + + if (!credentials) { + return new Response(JSON.stringify({ + error: 'Credentials not found', + message: 'No ESPN credentials found for user. Add your ESPN credentials at /settings/espn' + }), { + status: 404, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + return new Response(JSON.stringify({ + success: true, + platform: 'espn', + credentials + }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + // ============================================================================= // LEAGUES ENDPOINTS // ============================================================================= @@ -1201,12 +1315,34 @@ api.delete('/leagues', async (c) => { return handleLeagues(c, 'DELETE'); }); +api.get('/internal/leagues', async (c) => { + const { userId: clerkUserId, error: authError } = await getInternalUserId(c.req.raw, c.env, undefined, { allowEvalApiKey: true }); + if (!clerkUserId) { + const status = authError?.includes(INTERNAL_SERVICE_TOKEN_HEADER) || authError?.includes('Internal service authentication') ? 403 : 401; + return c.json({ + error: 'Authentication required', + message: authError || 'Missing or invalid Authorization token' + }, status); + } + + const storage = EspnSupabaseStorage.fromEnvironment(c.env); + const leagues = await storage.getLeagues(clerkUserId); + const leaguesWithPlatform = leagues.map(league => ({ + ...league, + platform: 'espn' as const + })); + + return c.json({ + success: true, + leagues: leaguesWithPlatform, + totalLeagues: leagues.length + }); +}); + async function handleLeagues(c: Context<{ Bindings: Env }>, method: string): Promise { const env = c.env; const url = new URL(c.req.url); - const corsHeaders = getCorsHeaders(c.req.raw); - - const { userId: clerkUserId, error: authError } = await getVerifiedUserId(c.req.raw, env, undefined, { allowEvalApiKey: method === 'GET' }); + const { userId: clerkUserId, error: authError } = await getClerkUserId(c.req.raw, env); if (!clerkUserId) { return c.json({ error: 'Authentication required', @@ -1291,7 +1427,7 @@ async function handleLeagues(c: Context<{ Bindings: Env }>, method: string): Pro // Set default league endpoint api.post('/leagues/default', async (c) => { - const { userId: clerkUserId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId: clerkUserId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!clerkUserId) { return c.json({ error: 'Authentication required', @@ -1352,7 +1488,7 @@ api.post('/leagues/default', async (c) => { // Clear default league for a sport api.delete('/leagues/default/:sport', async (c) => { - const { userId: clerkUserId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId: clerkUserId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!clerkUserId) { return c.json({ error: 'Authentication required', @@ -1392,7 +1528,7 @@ api.delete('/leagues/default/:sport', async (c) => { // Add single league endpoint api.post('/leagues/add', async (c) => { - const { userId: clerkUserId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId: clerkUserId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!clerkUserId) { return c.json({ error: 'Authentication required', @@ -1431,7 +1567,7 @@ api.post('/leagues/add', async (c) => { api.patch('/leagues/:leagueId/team', async (c) => { const leagueId = c.req.param('leagueId'); - const { userId: clerkUserId, error: authError } = await getVerifiedUserId(c.req.raw, c.env); + const { userId: clerkUserId, error: authError } = await getClerkUserId(c.req.raw, c.env); if (!clerkUserId) { return c.json({ error: 'Authentication required', @@ -1508,7 +1644,7 @@ api.notFound((c) => { endpoints: { '/health': 'GET - Health check with Supabase connectivity test', '/credentials/espn': 'GET/POST/DELETE - ESPN credential management', - '/credentials/espn?raw=true': 'GET - Retrieve actual credentials for sport workers', + '/internal/credentials/espn/raw': 'GET - Retrieve actual credentials for internal sport workers', '/leagues': 'GET/POST/DELETE - League management (list, store, remove)', '/leagues/default': 'POST - Set default league (requires platform, leagueId, sport, seasonYear)', '/leagues/default/:sport': 'DELETE - Clear default league for a sport', @@ -1529,10 +1665,11 @@ api.notFound((c) => { '/extension/discover': 'POST - Discover and save leagues', '/connect/yahoo/authorize': 'GET - Start Yahoo OAuth flow', '/connect/yahoo/callback': 'GET - Yahoo OAuth callback (public)', - '/connect/yahoo/credentials': 'GET - Get Yahoo access token', + '/internal/connect/yahoo/credentials': 'GET - Get Yahoo access token for internal workers', '/connect/yahoo/status': 'GET - Check Yahoo connection status', '/connect/yahoo/disconnect': 'DELETE - Disconnect Yahoo account', '/user/preferences': 'GET - Get user preferences (default sport and per-sport defaults)', + '/internal/user/preferences': 'GET - Get user preferences for internal workers', '/user/preferences/default-sport': 'POST - Set user default sport', }, storage: 'supabase', diff --git a/workers/auth-worker/wrangler.jsonc b/workers/auth-worker/wrangler.jsonc index d85bf9e9..913ef03e 100644 --- a/workers/auth-worker/wrangler.jsonc +++ b/workers/auth-worker/wrangler.jsonc @@ -24,26 +24,25 @@ // - YAHOO_CLIENT_ID (Yahoo Developer App) // - YAHOO_CLIENT_SECRET (Yahoo Developer App) - // Rate limiting (Cloudflare Workers native — zero-latency, no DB calls) - "rate_limits": [ - { - "binding": "TOKEN_RATE_LIMITER", - "namespace_id": "1001", - "simple": { "limit": 10, "period": 60 } - }, - { - "binding": "CREDENTIALS_RATE_LIMITER", - "namespace_id": "1002", - "simple": { "limit": 15, "period": 60 } - } - ], - // Environment configurations "env": { // Production environment "prod": { "name": "auth-worker", "workers_dev": true, // Enable .workers.dev URL for worker-to-worker calls + // Cloudflare native rate limiting is configured per-environment. + "ratelimits": [ + { + "name": "TOKEN_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 10, "period": 60 } + }, + { + "name": "CREDENTIALS_RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 15, "period": 60 } + } + ], // Custom domain routes for production (external clients) // OAuth routes at root level for MCP spec compliance "routes": [ @@ -84,6 +83,18 @@ "preview": { "name": "auth-worker-preview", "workers_dev": true, // Also keep workers.dev URL available + "ratelimits": [ + { + "name": "TOKEN_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 10, "period": 60 } + }, + { + "name": "CREDENTIALS_RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 15, "period": 60 } + } + ], // Custom domain routes for preview (needed for worker-to-worker calls) // Note: OAuth routes are shared with prod at base URL (api.flaim.app) // Preview testers can use .workers.dev URLs for isolated testing @@ -105,6 +116,18 @@ "dev": { "name": "auth-worker-dev", "workers_dev": true, // Use workers.dev URL for development + "ratelimits": [ + { + "name": "TOKEN_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 10, "period": 60 } + }, + { + "name": "CREDENTIALS_RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 15, "period": 60 } + } + ], // Environment variables for dev // Note: SUPABASE_URL and SUPABASE_SERVICE_KEY loaded from .env.local for local development "vars": { diff --git a/workers/espn-client/README.md b/workers/espn-client/README.md index fb8001c7..23d6a903 100644 --- a/workers/espn-client/README.md +++ b/workers/espn-client/README.md @@ -30,8 +30,6 @@ espn-client |----------|--------|---------| | `/health` | GET | Health check | | `/execute` | POST | Execute tool (internal only) | -| `/onboarding/initialize` | POST | Initialize onboarding with league data (requires Authorization header) | -| `/onboarding/discover-seasons` | POST | Discover and save historical seasons (requires Authorization header) | ### `/execute` Request Format @@ -48,10 +46,11 @@ interface ExecuteRequest { count?: number; type?: string; // Transaction type filter (add, drop, trade, waiver, trade_proposal, trade_decline, trade_veto, trade_uphold, failed_bid) }; - authHeader?: string; // Bearer token for auth-worker } ``` +`/execute` reads end-user auth from the HTTP `Authorization` header and requires `X-Flaim-Internal-Token` for internal calls. Manual ESPN onboarding now runs in the web server layer, not through public ESPN worker routes. + ## Supported Tools All four sports (football, baseball, basketball, hockey) support the same 7 tools: diff --git a/workers/espn-client/src/__tests__/types.test.ts b/workers/espn-client/src/__tests__/types.test.ts index 778c6b68..ede05159 100644 --- a/workers/espn-client/src/__tests__/types.test.ts +++ b/workers/espn-client/src/__tests__/types.test.ts @@ -47,10 +47,9 @@ describe('espn-client types', () => { league_id: '12345', season_year: 2024, }, - authHeader: 'Bearer token123', }; expect(request.tool).toBe('get_standings'); - expect(request.authHeader).toBe('Bearer token123'); + expect(request.params.league_id).toBe('12345'); }); }); diff --git a/workers/espn-client/src/index.ts b/workers/espn-client/src/index.ts index e17c5a56..535b120c 100644 --- a/workers/espn-client/src/index.ts +++ b/workers/espn-client/src/index.ts @@ -1,5 +1,5 @@ // workers/espn-client/src/index.ts -import { Hono } from 'hono'; +import { Context, Hono } from 'hono'; import { cors } from 'hono/cors'; import type { Env, ExecuteRequest, ExecuteResponse, Sport, ToolParams } from './types'; import { baseballHandlers } from './sports/baseball/handlers'; @@ -12,9 +12,9 @@ import { EVAL_TRACE_HEADER, getCorrelationId, getEvalContext, + hasValidInternalServiceToken, + INTERNAL_SERVICE_TOKEN_HEADER, } from '@flaim/worker-shared'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; -import { discoverSeasons, initializeOnboarding } from './onboarding/handlers'; import { toEspnSeasonYear } from './shared/season'; import { logEvalEvent } from './logging'; @@ -22,6 +22,26 @@ const app = new Hono<{ Bindings: Env }>(); app.use('*', cors()); +async function requireInternalService(c: Context<{ Bindings: Env }>, target: string): Promise { + if (!c.env.INTERNAL_SERVICE_TOKEN) { + return c.json({ + success: false, + error: `INTERNAL_SERVICE_TOKEN is not configured for ${target}`, + code: 'INTERNAL_AUTH_NOT_CONFIGURED', + }, 500); + } + + if (!(await hasValidInternalServiceToken(c.req.raw, c.env))) { + return c.json({ + success: false, + error: `Missing or invalid ${INTERNAL_SERVICE_TOKEN_HEADER}`, + code: 'INTERNAL_AUTH_REQUIRED', + }, 403); + } + + return null; +} + // Health check app.get('/health', (c) => { return c.json({ @@ -32,43 +52,16 @@ app.get('/health', (c) => { }); }); -// Onboarding initialize endpoint (manual ESPN flow) -app.post('/onboarding/initialize', async (c) => { - const correlationId = getCorrelationId(c.req.raw); - const authHeader = c.req.header('Authorization'); - const body = await c.req.json().catch(() => ({})) as { - sport?: string; - leagueId?: string; - seasonYear?: number; - }; - - const result = await initializeOnboarding(c.env, body, authHeader, correlationId); - const response = c.json(result.body, { status: result.status as ContentfulStatusCode }); - response.headers.set(CORRELATION_ID_HEADER, correlationId); - return response; -}); - -// Onboarding discover seasons endpoint (manual ESPN flow) -app.post('/onboarding/discover-seasons', async (c) => { - const correlationId = getCorrelationId(c.req.raw); - const authHeader = c.req.header('Authorization'); - const body = await c.req.json().catch(() => ({})) as { - sport?: string; - leagueId?: string; - }; - - const result = await discoverSeasons(c.env, body, authHeader, correlationId); - const response = c.json(result.body, { status: result.status as ContentfulStatusCode }); - response.headers.set(CORRELATION_ID_HEADER, correlationId); - return response; -}); - // Main execute endpoint - called by fantasy-mcp gateway via service binding app.post('/execute', async (c) => { + const internalAuthError = await requireInternalService(c, '/execute'); + if (internalAuthError) return internalAuthError; + const correlationId = getCorrelationId(c.req.raw); const { evalRunId, evalTraceId } = getEvalContext(c.req.raw); const body = await c.req.json(); - const { tool, params, authHeader } = body; + const { tool, params } = body; + const authHeader = c.req.header('Authorization'); const { sport, league_id, season_year } = params; // Translate canonical start-year to ESPN-native before routing to handlers. @@ -210,8 +203,6 @@ app.notFound((c) => { error: 'Endpoint not found', endpoints: { '/health': 'GET - Health check', - '/onboarding/initialize': 'POST - Initialize onboarding with league data (requires Authorization header)', - '/onboarding/discover-seasons': 'POST - Discover and save historical seasons (requires Authorization header)', '/execute': 'POST - Execute tool (called by gateway)' } }, 404); diff --git a/workers/espn-client/src/onboarding/basic-league-info.ts b/workers/espn-client/src/onboarding/basic-league-info.ts deleted file mode 100644 index 8db70e27..00000000 --- a/workers/espn-client/src/onboarding/basic-league-info.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { EspnCredentials } from '@flaim/worker-shared'; -import type { EspnLeagueResponse } from '../types'; -import { espnFetch } from '../shared/espn-api'; -import { toEspnSeasonYear } from '../shared/season'; - -export interface BasicLeagueInfoRequest { - leagueId: string; - sport: string; - gameId: string; - credentials: EspnCredentials; - seasonYear?: number; -} - -export interface BasicLeagueInfoResponse { - success: boolean; - leagueName?: string; - seasonYear?: number; - standings?: Array<{ - teamId: string; - teamName: string; - wins: number; - losses: number; - ties: number; - winPercentage: number; - rank: number; - playoffSeed?: number; - }>; - teams?: Array<{ - teamId: string; - teamName: string; - ownerName?: string; - }>; - error?: string; - httpStatus?: number; -} - -export async function getBasicLeagueInfo( - request: BasicLeagueInfoRequest -): Promise { - try { - const { leagueId, gameId, credentials, sport, seasonYear: requestedSeasonYear } = request; - - if (!leagueId || !gameId || !credentials?.swid || !credentials?.s2) { - return { - success: false, - error: 'Missing required parameters: leagueId, gameId, swid, s2', - httpStatus: 400 - }; - } - - const seasonYear = requestedSeasonYear || new Date().getFullYear(); - const espnSeasonYear = toEspnSeasonYear(seasonYear, sport); - const apiPath = `/seasons/${espnSeasonYear}/segments/0/leagues/${leagueId}?view=mStandings&view=mTeam&view=mSettings`; - - let response: Response; - try { - response = await espnFetch(apiPath, gameId, { - credentials, - timeout: 7000, - headers: { - 'User-Agent': 'flaim-onboarding-autopull/1.0' - } - }); - } catch (fetchError) { - if (fetchError instanceof Error && fetchError.name === 'AbortError') { - return { - success: false, - error: 'ESPN API request timed out - try again', - httpStatus: 504 - }; - } - throw fetchError; - } - - if (response.status === 401 || response.status === 403) { - return { - success: false, - error: 'ESPN authentication failed - please verify your cookies are current and valid', - httpStatus: response.status - }; - } - - if (response.status === 404) { - return { - success: false, - error: 'League not found - please check your league ID and sport selection', - httpStatus: 404 - }; - } - - if (response.status === 429) { - return { - success: false, - error: 'ESPN API rate limited - try again later', - httpStatus: 429 - }; - } - - if (!response.ok) { - return { - success: false, - error: `ESPN API error: ${response.status} ${response.statusText}`, - httpStatus: response.status - }; - } - - const responseText = await response.text(); - - let data: EspnLeagueResponse; - try { - data = JSON.parse(responseText); - } catch (parseError) { - if (responseText.includes(' ({ - teamId: team.id?.toString() || '', - teamName: team.location && team.nickname - ? `${team.location} ${team.nickname}` - : team.name || `Team ${team.id}`, - ownerName: team.owners?.[0]?.displayName || team.owners?.[0]?.firstName || undefined - })); - - const standings = (data.teams || []).map((team) => { - const record = team.record?.overall; - const wins = record?.wins || 0; - const losses = record?.losses || 0; - const ties = record?.ties || 0; - const totalGames = wins + losses + ties; - const winPercentage = totalGames > 0 ? wins / totalGames : 0; - - return { - teamId: team.id?.toString() || '', - teamName: team.location && team.nickname - ? `${team.location} ${team.nickname}` - : team.name || `Team ${team.id}`, - wins, - losses, - ties, - winPercentage: Math.round(winPercentage * 1000) / 1000, - rank: team.playoffSeed || team.rank || 0, - playoffSeed: team.playoffSeed || undefined - }; - }).sort((a, b) => { - if (b.winPercentage !== a.winPercentage) { - return b.winPercentage - a.winPercentage; - } - return b.wins - a.wins; - }).map((team, index) => ({ - ...team, - rank: index + 1 - })); - - return { - success: true, - leagueName, - seasonYear: returnedSeasonYear, - standings, - teams - }; - - } catch (error) { - console.error(`Basic ${request.sport || 'league'} info error:`, error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - httpStatus: 500 - }; - } -} diff --git a/workers/espn-client/src/onboarding/handlers.ts b/workers/espn-client/src/onboarding/handlers.ts deleted file mode 100644 index f0a14553..00000000 --- a/workers/espn-client/src/onboarding/handlers.ts +++ /dev/null @@ -1,520 +0,0 @@ -import type { LeagueConfig } from '@flaim/worker-shared'; -import { authWorkerFetch, withCorrelationId } from '@flaim/worker-shared'; -import type { Env, Sport } from '../types'; -import { getCredentials } from '../shared/auth'; -import { getBasicLeagueInfo } from './basic-league-info'; - -interface OnboardingResult { - status: number; - body: T; -} - -interface OnboardingInitializeRequest { - sport?: string; - leagueId?: string; - seasonYear?: number; -} - -interface DiscoverSeasonsRequest { - sport?: string; - leagueId?: string; -} - -interface DiscoveredSeason { - seasonYear: number; - leagueName: string; - teamCount: number; - teamId?: string; - teamName?: string; -} - -const ESPN_GAME_IDS: Record = { - football: 'ffl', - baseball: 'flb', - basketball: 'fba', - hockey: 'fhl' -}; - -function normalizeSport(input?: string): Sport | null { - if (!input) return null; - const normalized = input.toLowerCase(); - if (normalized === 'football' || normalized === 'baseball' || normalized === 'basketball' || normalized === 'hockey') { - return normalized as Sport; - } - return null; -} - -function getEspnGameId(sport: Sport): string { - return ESPN_GAME_IDS[sport] || 'ffl'; -} - -function isOnboardingSport(sport: Sport): boolean { - return sport === 'football' || sport === 'baseball' || sport === 'basketball' || sport === 'hockey'; -} - -function buildAuthHeaders( - authHeader?: string | null, - correlationId?: string, - includeJson = false -): Headers { - const headers: Record = {}; - if (authHeader) { - headers['Authorization'] = authHeader; - } - if (includeJson) { - headers['Content-Type'] = 'application/json'; - } - return correlationId ? withCorrelationId(headers, correlationId) : new Headers(headers); -} - -async function getUserLeagues( - env: Env, - authHeader?: string | null, - correlationId?: string -): Promise { - const response = await authWorkerFetch(env, '/leagues', { - method: 'GET', - headers: buildAuthHeaders(authHeader, correlationId, true) - }); - - if (response.status === 404) { - return []; - } - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) as { error?: string }; - throw new Error(`Auth-worker error: ${errorData.error || response.statusText}`); - } - - const data = await response.json().catch(() => null) as { success?: boolean; leagues?: LeagueConfig[] } | null; - if (!data?.success) { - return []; - } - return data.leagues || []; -} - -export async function initializeOnboarding( - env: Env, - body: OnboardingInitializeRequest, - authHeader?: string | null, - correlationId?: string -): Promise>> { - try { - if (!authHeader) { - return { - status: 401, - body: { error: 'Authentication required' } - }; - } - - const requestedSport = normalizeSport(body.sport); - if (body.sport && !requestedSport) { - return { - status: 400, - body: { error: 'Unsupported sport', code: 'SPORT_NOT_SUPPORTED' } - }; - } - - const targetSport = requestedSport ?? 'football'; - if (!isOnboardingSport(targetSport)) { - return { - status: 400, - body: { error: 'Sport not supported for onboarding yet', code: 'SPORT_NOT_SUPPORTED' } - }; - } - - const credentials = await getCredentials(env, authHeader, correlationId); - if (!credentials) { - return { - status: 404, - body: { - error: 'ESPN credentials not found. Please add your ESPN credentials first.', - code: 'CREDENTIALS_MISSING' - } - }; - } - - let targetLeagues: LeagueConfig[] = []; - - if (body.leagueId) { - targetLeagues = [{ leagueId: body.leagueId, sport: targetSport, seasonYear: body.seasonYear }]; - } else { - const leagues = await getUserLeagues(env, authHeader, correlationId); - targetLeagues = leagues.filter((league) => league.sport === targetSport); - - if (targetLeagues.length === 0) { - return { - status: 404, - body: { - error: `No ${targetSport} leagues found. Please add ${targetSport} leagues first.`, - code: 'LEAGUES_MISSING' - } - }; - } - } - - const leagueResults = []; - for (const league of targetLeagues) { - try { - const leagueSeasonYear = league.seasonYear ?? body.seasonYear; - const gameId = getEspnGameId(league.sport as Sport); - const basicInfo = await getBasicLeagueInfo({ - leagueId: league.leagueId, - sport: league.sport, - gameId, - credentials, - seasonYear: leagueSeasonYear - }); - - leagueResults.push({ - ...basicInfo, - leagueId: league.leagueId, - sport: league.sport, - teamId: league.teamId, - seasonYear: leagueSeasonYear, - gameId, - }); - } catch (error) { - console.error(`❌ Failed to get info for league ${league.leagueId}:`, error); - leagueResults.push({ - leagueId: league.leagueId, - sport: league.sport, - teamId: league.teamId, - gameId: getEspnGameId(league.sport as Sport), - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - - return { - status: 200, - body: { - success: true, - message: 'Onboarding initialized successfully', - sport: targetSport, - totalLeagues: targetLeagues.length, - leagues: leagueResults - } - }; - } catch (error) { - console.error('❌ Onboarding initialize error:', error); - return { - status: 500, - body: { - error: 'Failed to initialize onboarding', - details: error instanceof Error ? error.message : 'Unknown error' - } - }; - } -} - -export async function discoverSeasons( - env: Env, - body: DiscoverSeasonsRequest, - authHeader?: string | null, - correlationId?: string -): Promise>> { - try { - if (!authHeader) { - return { - status: 401, - body: { error: 'Authentication required', code: 'AUTH_MISSING' } - }; - } - - const leagueId = body.leagueId; - if (!leagueId) { - return { - status: 400, - body: { error: 'leagueId is required' } - }; - } - - const requestedSport = normalizeSport(body.sport); - if (body.sport && !requestedSport) { - return { - status: 400, - body: { error: 'Unsupported sport', code: 'SPORT_NOT_SUPPORTED' } - }; - } - - const targetSport = requestedSport ?? 'football'; - if (!isOnboardingSport(targetSport)) { - return { - status: 400, - body: { error: 'Sport not supported for onboarding yet', code: 'SPORT_NOT_SUPPORTED' } - }; - } - - const credentials = await getCredentials(env, authHeader, correlationId); - if (!credentials) { - return { - status: 404, - body: { error: 'ESPN credentials not found', code: 'CREDENTIALS_MISSING' } - }; - } - - const leaguesResponse = await authWorkerFetch(env, '/leagues', { - method: 'GET', - headers: buildAuthHeaders(authHeader, correlationId) - }); - const leaguesData = await leaguesResponse.json().catch(() => ({})) as { - leagues?: Array<{ leagueId: string; sport: string; seasonYear?: number; teamId?: string }>; - }; - - const matchingLeagues = (leaguesData.leagues || []).filter( - (league) => league.leagueId === leagueId && league.sport === targetSport - ); - const baseTeamId = matchingLeagues.find((league) => league.teamId)?.teamId; - - if (!baseTeamId) { - return { - status: 400, - body: { error: 'Team selection required before discovering seasons', code: 'TEAM_ID_MISSING' } - }; - } - - const existingSeasons = new Set( - matchingLeagues - .map((league) => league.seasonYear) - .filter((seasonYear): seasonYear is number => typeof seasonYear === 'number') - ); - - const MIN_YEAR = 2000; - const MAX_CONSECUTIVE_MISSES = 2; - const PROBE_DELAY_MS = 200; - const currentYear = new Date().getFullYear(); - const gameId = getEspnGameId(targetSport); - - const discovered: DiscoveredSeason[] = []; - let consecutiveMisses = 0; - let skippedCount = 0; - let rateLimited = false; - let limitExceeded = false; - let minYearReached = false; - - for (let year = currentYear; year >= MIN_YEAR; year--) { - if (year === MIN_YEAR) { - minYearReached = true; - } - - if (existingSeasons.has(year)) { - skippedCount++; - continue; - } - - const mustProbe = year >= currentYear - 1; - if (!mustProbe && consecutiveMisses >= MAX_CONSECUTIVE_MISSES) { - break; - } - - if (discovered.length > 0 || consecutiveMisses > 0) { - await new Promise((resolve) => setTimeout(resolve, PROBE_DELAY_MS)); - } - - const info = await getBasicLeagueInfo({ - leagueId, - sport: targetSport, - gameId, - credentials, - seasonYear: year - }); - - if (info.success && (!info.teams || info.teams.length === 0)) { - consecutiveMisses++; - continue; - } - - if (info.success) { - const matchedTeam = info.teams?.find((team) => team.teamId === baseTeamId); - const seasonTeamName = matchedTeam?.teamName; - discovered.push({ - seasonYear: year, - leagueName: info.leagueName || `${targetSport} League ${leagueId}`, - teamCount: info.teams?.length || 0, - teamId: baseTeamId, - teamName: seasonTeamName - }); - consecutiveMisses = 0; - - try { - const addResponse = await authWorkerFetch(env, '/leagues/add', { - method: 'POST', - headers: buildAuthHeaders(authHeader, correlationId, true), - body: JSON.stringify({ - leagueId, - sport: targetSport, - seasonYear: year, - leagueName: info.leagueName, - teamId: baseTeamId, - teamName: seasonTeamName - }) - }); - - if (addResponse.status === 409) { - try { - const patchResponse = await authWorkerFetch(env, `/leagues/${leagueId}/team`, { - method: 'PATCH', - headers: buildAuthHeaders(authHeader, correlationId, true), - body: JSON.stringify({ - teamId: baseTeamId, - sport: targetSport, - teamName: seasonTeamName, - leagueName: info.leagueName, - seasonYear: year - }) - }); - - if (!patchResponse.ok) { - const patchError = await patchResponse.json().catch(() => ({})) as { error?: string }; - console.warn(`⚠️ [discover] Failed to backfill team for season ${year}: ${patchResponse.status} ${patchError.error || ''}`); - } - } catch (patchError) { - console.warn(`⚠️ [discover] Error backfilling team for season ${year}:`, patchError); - } - } else if (addResponse.status === 400) { - const addData = await addResponse.json().catch(() => ({})) as { code?: string }; - if (addData.code === 'LIMIT_EXCEEDED') { - limitExceeded = true; - break; - } - } else if (!addResponse.ok) { - console.warn(`⚠️ [discover] Failed to save season ${year}: ${addResponse.status}`); - } - } catch (addError) { - console.warn(`⚠️ [discover] Error saving season ${year}:`, addError); - } - - } else if (info.httpStatus === 404) { - consecutiveMisses++; - } else if (info.httpStatus === 429) { - rateLimited = true; - break; - } else if (info.httpStatus === 401 || info.httpStatus === 403) { - const hasKnownSeason = discovered.length > 0 || existingSeasons.size > 0; - if (hasKnownSeason) { - consecutiveMisses++; - } else { - return { - status: 401, - body: { error: 'ESPN credentials expired or invalid', code: 'AUTH_FAILED' } - }; - } - } else { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const retry = await getBasicLeagueInfo({ - leagueId, - sport: targetSport, - gameId, - credentials, - seasonYear: year - }); - - if (retry.success && (!retry.teams || retry.teams.length === 0)) { - consecutiveMisses++; - continue; - } - - if (retry.success) { - const matchedTeam = retry.teams?.find((team) => team.teamId === baseTeamId); - const seasonTeamName = matchedTeam?.teamName; - discovered.push({ - seasonYear: year, - leagueName: retry.leagueName || `${targetSport} League ${leagueId}`, - teamCount: retry.teams?.length || 0, - teamId: baseTeamId, - teamName: seasonTeamName - }); - consecutiveMisses = 0; - try { - const addResponse = await authWorkerFetch(env, '/leagues/add', { - method: 'POST', - headers: buildAuthHeaders(authHeader, correlationId, true), - body: JSON.stringify({ - leagueId, - sport: targetSport, - seasonYear: year, - leagueName: retry.leagueName, - teamId: baseTeamId, - teamName: seasonTeamName - }) - }); - - if (addResponse.status === 409) { - try { - const patchResponse = await authWorkerFetch(env, `/leagues/${leagueId}/team`, { - method: 'PATCH', - headers: buildAuthHeaders(authHeader, correlationId, true), - body: JSON.stringify({ - teamId: baseTeamId, - sport: targetSport, - teamName: seasonTeamName, - leagueName: retry.leagueName, - seasonYear: year - }) - }); - if (!patchResponse.ok) { - const patchError = await patchResponse.json().catch(() => ({})) as { error?: string }; - console.warn(`⚠️ [discover] Failed to backfill team for season ${year} on retry: ${patchResponse.status} ${patchError.error || ''}`); - } - } catch (patchError) { - console.warn(`⚠️ [discover] Error backfilling team for season ${year} on retry:`, patchError); - } - } else if (addResponse.status === 400) { - const addData = await addResponse.json().catch(() => ({})) as { code?: string }; - if (addData.code === 'LIMIT_EXCEEDED') { - limitExceeded = true; - break; - } - } - } catch { - // Ignore save errors - } - } else if (retry.httpStatus === 404) { - consecutiveMisses++; - } else if (retry.httpStatus === 401 || retry.httpStatus === 403) { - const hasKnownSeason = discovered.length > 0 || existingSeasons.size > 0; - if (hasKnownSeason) { - consecutiveMisses++; - } else { - return { - status: 401, - body: { error: 'ESPN credentials expired or invalid', code: 'AUTH_FAILED' } - }; - } - } else { - return { - status: 502, - body: { error: `ESPN API error: ${retry.error}`, code: 'ESPN_ERROR' } - }; - } - } - } - - return { - status: 200, - body: { - success: true, - leagueId, - sport: targetSport, - startYear: currentYear, - minYearReached, - rateLimited, - limitExceeded, - discovered, - skipped: skippedCount, - ...(limitExceeded ? { error: 'League limit reached - some seasons may not have been saved' } : {}) - } - }; - } catch (error) { - console.error('❌ Discover seasons error:', error); - return { - status: 500, - body: { - error: 'Failed to discover seasons', - details: error instanceof Error ? error.message : 'Unknown error' - } - }; - } -} diff --git a/workers/espn-client/src/shared/auth.ts b/workers/espn-client/src/shared/auth.ts index 1b28bbcb..b073d3be 100644 --- a/workers/espn-client/src/shared/auth.ts +++ b/workers/espn-client/src/shared/auth.ts @@ -18,7 +18,7 @@ export async function getCredentials( headers['X-Correlation-ID'] = correlationId; } - const response = await authWorkerFetch(env, '/credentials/espn?raw=true', { + const response = await authWorkerFetch(env, '/internal/credentials/espn/raw', { method: 'GET', headers }); diff --git a/workers/espn-client/src/types.ts b/workers/espn-client/src/types.ts index da8436f0..591fa1f1 100644 --- a/workers/espn-client/src/types.ts +++ b/workers/espn-client/src/types.ts @@ -134,7 +134,6 @@ export type Sport = 'football' | 'baseball' | 'basketball' | 'hockey'; export interface ExecuteRequest { tool: string; params: ToolParams; - authHeader?: string; } export interface ToolParams { diff --git a/workers/espn-client/wrangler.jsonc b/workers/espn-client/wrangler.jsonc index 05dbf229..b18f8667 100644 --- a/workers/espn-client/wrangler.jsonc +++ b/workers/espn-client/wrangler.jsonc @@ -23,7 +23,7 @@ // Production environment "prod": { "name": "espn-client", - "workers_dev": true, + "workers_dev": false, "kv_namespaces": [ { "binding": "ESPN_PLAYERS_CACHE", "id": "8aed89c806d34c53aa5edf53c26f9d32", "preview_id": "ed5d96feef944254af880eafe9c131b3" } ], @@ -41,7 +41,7 @@ // Preview environment (remote dev/staging) "preview": { "name": "espn-client-preview", - "workers_dev": true, + "workers_dev": false, "kv_namespaces": [ { "binding": "ESPN_PLAYERS_CACHE", "id": "ed5d96feef944254af880eafe9c131b3", "preview_id": "ed5d96feef944254af880eafe9c131b3" } ], diff --git a/workers/fantasy-mcp/src/__tests__/router.test.ts b/workers/fantasy-mcp/src/__tests__/router.test.ts index 832068e9..2eb1887c 100644 --- a/workers/fantasy-mcp/src/__tests__/router.test.ts +++ b/workers/fantasy-mcp/src/__tests__/router.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { routeToClient, type RouteResult } from '../router'; import type { Env, ToolParams } from '../types'; +import { INTERNAL_SERVICE_TOKEN_HEADER } from '@flaim/worker-shared'; describe('fantasy-mcp router', () => { describe('RouteResult interface', () => { @@ -42,19 +43,20 @@ describe('fantasy-mcp router', () => { data: { standings: [] }, }; const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', ESPN: { fetch: async (request: Request) => { expect(request.method).toBe('POST'); expect(request.url).toBe('https://internal/execute'); expect(request.headers.get('Content-Type')).toBe('application/json'); expect(request.headers.get('Authorization')).toBe(authHeader); + expect(request.headers.get(INTERNAL_SERVICE_TOKEN_HEADER)).toBe('internal-secret'); expect(request.headers.get('X-Correlation-ID')).toBe(correlationId); expect(request.headers.get('X-Flaim-Eval-Run')).toBe(evalRunId); expect(request.headers.get('X-Flaim-Eval-Trace')).toBe(evalTraceId); expect(await request.json()).toEqual({ tool: 'get_standings', params, - authHeader, }); return new Response(JSON.stringify(responseBody), { status: 200, @@ -113,13 +115,14 @@ describe('fantasy-mcp router', () => { }; const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', SLEEPER: { fetch: async (request: Request) => { expect(request.url).toBe('https://internal/execute'); + expect(request.headers.get(INTERNAL_SERVICE_TOKEN_HEADER)).toBe('internal-secret'); expect(await request.json()).toEqual({ tool: 'get_free_agents', params, - authHeader: undefined, }); return new Response(JSON.stringify(responseBody), { status: 200, diff --git a/workers/fantasy-mcp/src/__tests__/tools.test.ts b/workers/fantasy-mcp/src/__tests__/tools.test.ts index 0ac96d40..12bb98ce 100644 --- a/workers/fantasy-mcp/src/__tests__/tools.test.ts +++ b/workers/fantasy-mcp/src/__tests__/tools.test.ts @@ -35,6 +35,7 @@ describe('fantasy-mcp tools', () => { expect(tool).toBeTruthy(); const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: async () => new Response('unauthorized', { status: 401 }), }, @@ -51,6 +52,7 @@ describe('fantasy-mcp tools', () => { expect(tool).toBeTruthy(); const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: async () => new Response(JSON.stringify({ leagues: [] }), { @@ -91,10 +93,11 @@ describe('fantasy-mcp tools', () => { ]; const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: async (req: Request) => { const url = new URL(req.url); - if (url.pathname === '/leagues') { + if (url.pathname === '/internal/leagues') { return new Response(JSON.stringify({ leagues: espnLeagues }), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -136,10 +139,11 @@ describe('fantasy-mcp tools', () => { ]; const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: async (req: Request) => { const url = new URL(req.url); - if (url.pathname === '/leagues') { + if (url.pathname === '/internal/leagues') { return new Response(JSON.stringify({ leagues: espnLeagues }), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -453,6 +457,7 @@ describe('auth error _meta', () => { const tool = getUnifiedTools().find((t) => t.name === 'get_user_session'); const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: async () => new Response('unauthorized', { status: 401 }), }, diff --git a/workers/fantasy-mcp/src/index.ts b/workers/fantasy-mcp/src/index.ts index ccdbb6a1..9e503925 100644 --- a/workers/fantasy-mcp/src/index.ts +++ b/workers/fantasy-mcp/src/index.ts @@ -8,6 +8,7 @@ import { EVAL_TRACE_HEADER, getCorrelationId, getEvalContext, + withInternalServiceToken, } from '@flaim/worker-shared'; import type { Env } from './types'; import { createFantasyMcpServer } from './mcp/server'; @@ -204,11 +205,11 @@ async function handleMcpRequest(c: Context<{ Bindings: Env }>): Promise Promise): Env { return { + INTERNAL_SERVICE_TOKEN: 'internal-secret', AUTH_WORKER: { fetch: authFetch } as unknown as Fetcher, ESPN: { fetch: vi.fn() } as unknown as Fetcher, YAHOO: { fetch: vi.fn() } as unknown as Fetcher, @@ -228,8 +230,9 @@ describe('fantasy-mcp gateway integration', () => { expect(authFetch).toHaveBeenCalledTimes(1); const introspectReq = authFetch.mock.calls[0]?.[0] as Request; - expect(introspectReq.url).toBe('https://internal/auth/introspect'); + expect(introspectReq.url).toBe('https://internal/internal/introspect'); expect(introspectReq.headers.get('X-Flaim-Expected-Resource')).toBe('https://api.flaim.app/mcp'); + expect(introspectReq.headers.get(INTERNAL_SERVICE_TOKEN_HEADER)).toBe('internal-secret'); }); it('fails closed with 401 when introspection returns non-OK', async () => { @@ -271,6 +274,7 @@ describe('fantasy-mcp gateway integration', () => { expect(authFetch).toHaveBeenCalledTimes(1); const introspectReq = authFetch.mock.calls[0]?.[0] as Request; expect(introspectReq.headers.get('X-Flaim-Expected-Resource')).toBe('https://api.flaim.app/fantasy/mcp'); + expect(introspectReq.headers.get(INTERNAL_SERVICE_TOKEN_HEADER)).toBe('internal-secret'); }); it('routes tools/call through to platform worker and returns shaped MCP response', async () => { @@ -286,12 +290,8 @@ describe('fantasy-mcp gateway integration', () => { { status: 200, headers: { 'Content-Type': 'application/json' } } ) ); - const env = { - AUTH_WORKER: { fetch: authFetch } as unknown as Fetcher, - ESPN: { fetch: espnFetch } as unknown as Fetcher, - YAHOO: { fetch: vi.fn() } as unknown as Fetcher, - SLEEPER: { fetch: vi.fn() } as unknown as Fetcher, - } as unknown as Env; + const env = buildEnv(authFetch); + env.ESPN = { fetch: espnFetch } as unknown as Fetcher; const request = new Request('https://api.flaim.app/mcp', { method: 'POST', diff --git a/workers/shared/src/auth-fetch.ts b/workers/shared/src/auth-fetch.ts index f15f9fe9..bd0eed8b 100644 --- a/workers/shared/src/auth-fetch.ts +++ b/workers/shared/src/auth-fetch.ts @@ -7,6 +7,10 @@ */ import type { BaseEnvWithAuth } from './types.js'; +import { + isProductionLikeEnvironment, + withInternalServiceToken, +} from './internal-service.js'; /** * Fetch from auth-worker using service binding (preferred) or URL fallback. @@ -31,16 +35,24 @@ export function authWorkerFetch( ): Promise { // Ensure path starts with / const safePath = path.startsWith('/') ? path : `/${path}`; + const requiresInternalToken = safePath.startsWith('/internal/'); + const headers = requiresInternalToken + ? withInternalServiceToken(init?.headers, env, `auth-worker path "${safePath}"`) + : new Headers(init?.headers); + const requestInit = { + ...init, + headers, + } satisfies RequestInit; // Prefer service binding if (env.AUTH_WORKER) { const url = new URL(safePath, 'https://auth-worker.internal'); - return env.AUTH_WORKER.fetch(new Request(url.toString(), init)); + return env.AUTH_WORKER.fetch(new Request(url.toString(), requestInit)); } - // Log warning in prod when binding is missing - if (env.ENVIRONMENT === 'prod') { - console.warn('[authWorkerFetch] AUTH_WORKER binding missing in prod; using URL fallback'); + // Internal auth-worker calls must stay on service bindings outside local dev. + if (isProductionLikeEnvironment(env)) { + throw new Error(`[authWorkerFetch] AUTH_WORKER binding is required for ${safePath} in ${env.ENVIRONMENT || env.NODE_ENV || 'production-like'} environments`); } // Fall back to URL @@ -48,5 +60,5 @@ export function authWorkerFetch( throw new Error('AUTH_WORKER_URL is not configured and AUTH_WORKER binding is unavailable'); } - return fetch(`${env.AUTH_WORKER_URL}${safePath}`, init); + return fetch(`${env.AUTH_WORKER_URL}${safePath}`, requestInit); } diff --git a/workers/shared/src/index.ts b/workers/shared/src/index.ts index 7ff06ba8..299db406 100644 --- a/workers/shared/src/index.ts +++ b/workers/shared/src/index.ts @@ -32,6 +32,15 @@ export { // Auth-worker fetch helper export { authWorkerFetch } from './auth-fetch.js'; +// Internal service auth utilities +export { + INTERNAL_SERVICE_TOKEN_HEADER, + hasValidInternalServiceToken, + isProductionLikeEnvironment, + requireInternalServiceToken, + withInternalServiceToken, +} from './internal-service.js'; + // Error utilities export { ErrorCode, extractErrorCode } from './errors.js'; export type { ErrorCodeValue, ExecuteResponse } from './errors.js'; diff --git a/workers/shared/src/internal-service.ts b/workers/shared/src/internal-service.ts new file mode 100644 index 00000000..8f58a804 --- /dev/null +++ b/workers/shared/src/internal-service.ts @@ -0,0 +1,51 @@ +export const INTERNAL_SERVICE_TOKEN_HEADER = 'X-Flaim-Internal-Token'; + +async function constantTimeEqual(a: string, b: string): Promise { + const encoder = new TextEncoder(); + const [aHash, bHash] = await Promise.all([ + crypto.subtle.digest('SHA-256', encoder.encode(a)), + crypto.subtle.digest('SHA-256', encoder.encode(b)), + ]); + const aArr = new Uint8Array(aHash); + const bArr = new Uint8Array(bHash); + let result = 0; + for (let i = 0; i < aArr.length; i += 1) { + result |= aArr[i] ^ bArr[i]; + } + return result === 0; +} + +export function isProductionLikeEnvironment(env?: { ENVIRONMENT?: string; NODE_ENV?: string }): boolean { + return env?.ENVIRONMENT === 'prod' || env?.ENVIRONMENT === 'preview' || env?.NODE_ENV === 'production'; +} + +export function requireInternalServiceToken(env: { INTERNAL_SERVICE_TOKEN?: string }, target: string): string { + if (!env.INTERNAL_SERVICE_TOKEN) { + throw new Error(`INTERNAL_SERVICE_TOKEN is required for ${target}`); + } + return env.INTERNAL_SERVICE_TOKEN; +} + +export function withInternalServiceToken( + headersInit: HeadersInit | undefined, + env: { INTERNAL_SERVICE_TOKEN?: string }, + target: string +): Headers { + const headers = new Headers(headersInit); + headers.set(INTERNAL_SERVICE_TOKEN_HEADER, requireInternalServiceToken(env, target)); + return headers; +} + +export async function hasValidInternalServiceToken( + request: Request, + env: { INTERNAL_SERVICE_TOKEN?: string } +): Promise { + if (!env.INTERNAL_SERVICE_TOKEN) { + return false; + } + const providedToken = request.headers.get(INTERNAL_SERVICE_TOKEN_HEADER); + if (!providedToken) { + return false; + } + return constantTimeEqual(providedToken, env.INTERNAL_SERVICE_TOKEN); +} diff --git a/workers/shared/src/types.ts b/workers/shared/src/types.ts index df403547..0328aced 100644 --- a/workers/shared/src/types.ts +++ b/workers/shared/src/types.ts @@ -11,6 +11,7 @@ export interface BaseEnvWithAuth { ENVIRONMENT?: string; AUTH_WORKER_URL: string; AUTH_WORKER?: Fetcher; // Service binding for auth-worker + INTERNAL_SERVICE_TOKEN?: string; } /** diff --git a/workers/sleeper-client/README.md b/workers/sleeper-client/README.md index ccb0101f..cd6baf6f 100644 --- a/workers/sleeper-client/README.md +++ b/workers/sleeper-client/README.md @@ -41,10 +41,11 @@ interface ExecuteRequest { team_id?: string; // Sleeper roster/user ID within the league week?: number; }; - authHeader?: string; // Bearer token forwarded to auth-worker for user lookup } ``` +`/execute` requires `X-Flaim-Internal-Token` for internal calls. + ## Supported Tools ### Football (NFL) diff --git a/workers/sleeper-client/src/__tests__/routing.test.ts b/workers/sleeper-client/src/__tests__/routing.test.ts index 7c70ff59..df815b81 100644 --- a/workers/sleeper-client/src/__tests__/routing.test.ts +++ b/workers/sleeper-client/src/__tests__/routing.test.ts @@ -16,11 +16,14 @@ function mockExecutionContext(): ExecutionContext { async function executeRequest( sport: string, tool = 'get_league_info', - env: Env = {} as Env + env: Env = { INTERNAL_SERVICE_TOKEN: 'internal-secret' } as Env ): Promise<{ success: boolean; code?: string }> { const req = new Request('https://internal/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Flaim-Internal-Token': 'internal-secret', + }, body: JSON.stringify({ tool, params: { sport, league_id: 'lg1', season_year: 2025 } }), }); const res = await app.fetch(req, env, mockExecutionContext()); @@ -50,6 +53,7 @@ describe('sleeper-client sport routing', () => { it('passes env bindings through execute route', async () => { const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', SLEEPER_PLAYERS_CACHE: {} as KVNamespace, } as Env; const handlerSpy = vi.spyOn(footballHandlers, 'get_league_info').mockImplementation(async (receivedEnv: Env) => ({ @@ -63,6 +67,7 @@ describe('sleeper-client sport routing', () => { it('returns UNKNOWN_TOOL for unknown football tool when env is provided', async () => { const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', SLEEPER_PLAYERS_CACHE: {} as KVNamespace, } as Env; const body = await executeRequest('football', 'unknown_tool_name', env); @@ -72,6 +77,7 @@ describe('sleeper-client sport routing', () => { it('routes get_free_agents for supported sport with structured success payload', async () => { const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', SLEEPER_PLAYERS_CACHE: { get: vi.fn().mockResolvedValueOnce(JSON.stringify([ { player_id: 'p1', full_name: 'Rostered', position: 'QB', team: 'BUF', active: true }, @@ -87,7 +93,10 @@ describe('sleeper-client sport routing', () => { const req = new Request('https://internal/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Flaim-Internal-Token': 'internal-secret', + }, body: JSON.stringify({ tool: 'get_free_agents', params: { sport: 'football', league_id: 'lg1', season_year: 2025, count: 10, position: 'QB' }, @@ -108,6 +117,7 @@ describe('sleeper-client sport routing', () => { it('routes get_free_agents for basketball with structured success payload', async () => { const env = { + INTERNAL_SERVICE_TOKEN: 'internal-secret', SLEEPER_PLAYERS_CACHE: { get: vi.fn().mockResolvedValueOnce(JSON.stringify([ { player_id: 'b1', full_name: 'Rostered Guard', position: 'PG', team: 'BOS', active: true }, @@ -123,7 +133,10 @@ describe('sleeper-client sport routing', () => { const req = new Request('https://internal/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Flaim-Internal-Token': 'internal-secret', + }, body: JSON.stringify({ tool: 'get_free_agents', params: { sport: 'basketball', league_id: 'lg2', season_year: 2025, count: 10, position: 'PG' }, @@ -141,4 +154,18 @@ describe('sleeper-client sport routing', () => { expect(body.data?.count).toBe(1); expect(body.data?.players?.[0]?.id).toBe('b2'); }); + + it('rejects execute without internal service token', async () => { + const req = new Request('https://internal/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: 'get_league_info', + params: { sport: 'football', league_id: 'lg1', season_year: 2025 }, + }), + }); + + const res = await app.fetch(req, { INTERNAL_SERVICE_TOKEN: 'internal-secret' } as Env, mockExecutionContext()); + expect(res.status).toBe(403); + }); }); diff --git a/workers/sleeper-client/src/index.ts b/workers/sleeper-client/src/index.ts index 73ae1191..5df0c45d 100644 --- a/workers/sleeper-client/src/index.ts +++ b/workers/sleeper-client/src/index.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Context, Hono } from 'hono'; import { cors } from 'hono/cors'; import type { Env, ExecuteRequest, ExecuteResponse, Sport } from './types'; import { footballHandlers } from './sports/football/handlers'; @@ -9,6 +9,8 @@ import { EVAL_TRACE_HEADER, getCorrelationId, getEvalContext, + hasValidInternalServiceToken, + INTERNAL_SERVICE_TOKEN_HEADER, } from '@flaim/worker-shared'; import { logEvalEvent } from './logging'; @@ -16,6 +18,26 @@ const app = new Hono<{ Bindings: Env }>(); app.use('*', cors()); +async function requireInternalService(c: Context<{ Bindings: Env }>, target: string): Promise { + if (!c.env.INTERNAL_SERVICE_TOKEN) { + return c.json({ + success: false, + error: `INTERNAL_SERVICE_TOKEN is not configured for ${target}`, + code: 'INTERNAL_AUTH_NOT_CONFIGURED', + }, 500); + } + + if (!(await hasValidInternalServiceToken(c.req.raw, c.env))) { + return c.json({ + success: false, + error: `Missing or invalid ${INTERNAL_SERVICE_TOKEN_HEADER}`, + code: 'INTERNAL_AUTH_REQUIRED', + }, 403); + } + + return null; +} + app.get('/health', (c) => { return c.json({ status: 'healthy', @@ -26,6 +48,9 @@ app.get('/health', (c) => { }); app.post('/execute', async (c) => { + const internalAuthError = await requireInternalService(c, '/execute'); + if (internalAuthError) return internalAuthError; + const correlationId = getCorrelationId(c.req.raw); const { evalRunId, evalTraceId } = getEvalContext(c.req.raw); const startTime = Date.now(); diff --git a/workers/sleeper-client/src/types.ts b/workers/sleeper-client/src/types.ts index 96814389..54ef0bc7 100644 --- a/workers/sleeper-client/src/types.ts +++ b/workers/sleeper-client/src/types.ts @@ -9,7 +9,6 @@ export type Sport = 'football' | 'basketball'; export interface ExecuteRequest { tool: string; params: ToolParams; - authHeader?: string; } export interface ToolParams { diff --git a/workers/sleeper-client/wrangler.jsonc b/workers/sleeper-client/wrangler.jsonc index 90bcd022..d7e5c7db 100644 --- a/workers/sleeper-client/wrangler.jsonc +++ b/workers/sleeper-client/wrangler.jsonc @@ -15,7 +15,7 @@ "env": { "prod": { "name": "sleeper-client", - "workers_dev": true, + "workers_dev": false, "kv_namespaces": [ { "binding": "SLEEPER_PLAYERS_CACHE", "id": "b34c0c797b7b4e1285458361694b8d5e", "preview_id": "81df4492a52c456c9f6362a5cc5a5816" } ], @@ -30,7 +30,7 @@ }, "preview": { "name": "sleeper-client-preview", - "workers_dev": true, + "workers_dev": false, "kv_namespaces": [ { "binding": "SLEEPER_PLAYERS_CACHE", "id": "81df4492a52c456c9f6362a5cc5a5816", "preview_id": "81df4492a52c456c9f6362a5cc5a5816" } ], diff --git a/workers/yahoo-client/README.md b/workers/yahoo-client/README.md index bd9062c1..23722db6 100644 --- a/workers/yahoo-client/README.md +++ b/workers/yahoo-client/README.md @@ -44,10 +44,11 @@ interface ExecuteRequest { position?: string; count?: number; }; - authHeader?: string; // Bearer token for auth-worker } ``` +`/execute` reads end-user auth from the HTTP `Authorization` header and requires `X-Flaim-Internal-Token` for internal calls. + ## Supported Tools ### Football diff --git a/workers/yahoo-client/src/index.ts b/workers/yahoo-client/src/index.ts index a7f9ce87..9ffe47f5 100644 --- a/workers/yahoo-client/src/index.ts +++ b/workers/yahoo-client/src/index.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Context, Hono } from 'hono'; import { cors } from 'hono/cors'; import type { Env, ExecuteRequest, ExecuteResponse, Sport } from './types'; import { baseballHandlers } from './sports/baseball/handlers'; @@ -11,6 +11,8 @@ import { EVAL_TRACE_HEADER, getCorrelationId, getEvalContext, + hasValidInternalServiceToken, + INTERNAL_SERVICE_TOKEN_HEADER, } from '@flaim/worker-shared'; import { logEvalEvent } from './logging'; @@ -18,6 +20,26 @@ const app = new Hono<{ Bindings: Env }>(); app.use('*', cors()); +async function requireInternalService(c: Context<{ Bindings: Env }>, target: string): Promise { + if (!c.env.INTERNAL_SERVICE_TOKEN) { + return c.json({ + success: false, + error: `INTERNAL_SERVICE_TOKEN is not configured for ${target}`, + code: 'INTERNAL_AUTH_NOT_CONFIGURED', + }, 500); + } + + if (!(await hasValidInternalServiceToken(c.req.raw, c.env))) { + return c.json({ + success: false, + error: `Missing or invalid ${INTERNAL_SERVICE_TOKEN_HEADER}`, + code: 'INTERNAL_AUTH_REQUIRED', + }, 403); + } + + return null; +} + app.get('/health', (c) => { return c.json({ status: 'healthy', @@ -28,13 +50,17 @@ app.get('/health', (c) => { }); app.post('/execute', async (c) => { + const internalAuthError = await requireInternalService(c, '/execute'); + if (internalAuthError) return internalAuthError; + const correlationId = getCorrelationId(c.req.raw); const { evalRunId, evalTraceId } = getEvalContext(c.req.raw); const startTime = Date.now(); try { const body = await c.req.json(); - const { tool, params, authHeader } = body; + const { tool, params } = body; + const authHeader = c.req.header('Authorization'); const { sport, league_id, season_year } = params; console.log(`[yahoo-client] ${correlationId} ${tool} ${sport} league=${league_id} season=${season_year}`); diff --git a/workers/yahoo-client/src/shared/auth.ts b/workers/yahoo-client/src/shared/auth.ts index 0ab5f88e..ec10a691 100644 --- a/workers/yahoo-client/src/shared/auth.ts +++ b/workers/yahoo-client/src/shared/auth.ts @@ -22,7 +22,7 @@ export async function getYahooCredentials( } // Call auth-worker to get Yahoo token (handles refresh automatically) - const response = await authWorkerFetch(env, '/connect/yahoo/credentials', { + const response = await authWorkerFetch(env, '/internal/connect/yahoo/credentials', { method: 'GET', headers }); @@ -62,7 +62,7 @@ export async function resolveUserTeamKey( if (authHeader) headers['Authorization'] = authHeader; if (correlationId) headers['X-Correlation-ID'] = correlationId; - const response = await authWorkerFetch(env, '/leagues/yahoo', { + const response = await authWorkerFetch(env, '/internal/leagues/yahoo', { method: 'GET', headers, }); diff --git a/workers/yahoo-client/src/types.ts b/workers/yahoo-client/src/types.ts index fafe8e0e..cf2b910c 100644 --- a/workers/yahoo-client/src/types.ts +++ b/workers/yahoo-client/src/types.ts @@ -9,7 +9,6 @@ export type Sport = 'football' | 'baseball' | 'basketball' | 'hockey'; export interface ExecuteRequest { tool: string; params: ToolParams; - authHeader?: string; } export interface ToolParams { diff --git a/workers/yahoo-client/wrangler.jsonc b/workers/yahoo-client/wrangler.jsonc index 2408a34e..60cef3bf 100644 --- a/workers/yahoo-client/wrangler.jsonc +++ b/workers/yahoo-client/wrangler.jsonc @@ -15,7 +15,7 @@ "env": { "prod": { "name": "yahoo-client", - "workers_dev": true, + "workers_dev": false, "services": [ { "binding": "AUTH_WORKER", "service": "auth-worker" } ], @@ -27,7 +27,7 @@ }, "preview": { "name": "yahoo-client-preview", - "workers_dev": true, + "workers_dev": false, "services": [ { "binding": "AUTH_WORKER", "service": "auth-worker-preview" } ], From 1f871a91c9eef26932fb290d9ff5aa802cbf4a33 Mon Sep 17 00:00:00 2001 From: Gerry Date: Thu, 12 Mar 2026 08:48:59 -0400 Subject: [PATCH 2/2] Encode ESPN league IDs in onboarding patch route --- web/lib/server/espn-onboarding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/lib/server/espn-onboarding.ts b/web/lib/server/espn-onboarding.ts index de5eebbe..88e2f73c 100644 --- a/web/lib/server/espn-onboarding.ts +++ b/web/lib/server/espn-onboarding.ts @@ -202,7 +202,7 @@ async function patchLeagueTeam( body: Record, correlationId?: string ): Promise { - return fetch(`${getAuthWorkerUrl()}/leagues/${leagueId}/team`, { + return fetch(`${getAuthWorkerUrl()}/leagues/${encodeURIComponent(leagueId)}/team`, { method: 'PATCH', headers: buildAuthHeaders(authHeader, correlationId, true), body: JSON.stringify(body),