import { nodecg } from './util/nodecg.js'; import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js'; // ─── Constantes ──────────────────────────────────────────────────────────────── const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token'; const CHALLONGE_OAUTH_SCOPES = [ 'me', 'tournaments:read', 'tournaments:write', 'matches:read', 'matches:write', 'participants:read', 'participants:write', ].join(' '); const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const RECENT_TOURNAMENTS_LIMIT = 20; // ─── URL del proxy OAuth ─────────────────────────────────────────────────────── // Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy. // Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev' // // También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl" // (útil para apuntar a un entorno de staging sin recompilar). const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev'; // ─── Tipos ───────────────────────────────────────────────────────────────────── interface OAuthTokenResponse { access_token?: string; error?: string; error_description?: string; message?: string; } interface RecentTournament { id: string; name: string; slug: string; startAt: number | null; endAt: number | null; } interface ImportedPlayer { id: string; gamertag: string; name: string; team: string; country: string; twitter: string; } // ─── Modo OAuth ──────────────────────────────────────────────────────────────── // // DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret. // El exchange se hace directamente contra Challonge. // // PROXY: No hay credenciales en la config local. // El clientId se obtiene del Worker (es público, no secreto). // El exchange lo hace el Worker, que guarda el clientSecret en sus env vars. type OAuthMode = | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; const getOAuthMode = (): OAuthMode => { const bundleConfig = nodecg.bundleConfig as Record; const clientId = String(bundleConfig.challongeClientId ?? '').trim(); const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim(); const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT); const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; const proxyBaseUrl = String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL; if (clientId && clientSecret) { nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)'); return { type: 'dev', clientId, clientSecret, callbackPort }; } nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`); return { type: 'proxy', proxyBaseUrl, callbackPort }; }; // ─── Exchange de token ───────────────────────────────────────────────────────── /** Modo dev: exchange directo con Challonge usando credenciales locales */ const exchangeCodeDirectly = async ( code: string, redirectUri: string, clientId: string, clientSecret: string, ): Promise => { const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }); const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const rawBody = await response.text(); let payload: OAuthTokenResponse; try { payload = JSON.parse(rawBody) as OAuthTokenResponse; } catch { payload = { message: rawBody }; } if (!response.ok) { throw new Error( payload.error_description ?? payload.error ?? payload.message ?? `OAuth token request failed (${response.status})`, ); } const token = String(payload.access_token ?? '').trim(); if (!token) { throw new Error( payload.error_description ?? payload.error ?? payload.message ?? 'OAuth token response did not include an access token', ); } return token; }; /** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */ const exchangeCodeViaProxy = async ( code: string, redirectUri: string, proxyBaseUrl: string, ): Promise => { const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, redirectUri }), }); const rawBody = await response.text(); let payload: { access_token?: string; error?: string }; try { payload = JSON.parse(rawBody) as typeof payload; } catch { payload = { error: rawBody }; } if (!response.ok) { throw new Error(payload.error ?? `Proxy responded with ${response.status}`); } const token = String(payload.access_token ?? '').trim(); if (!token) throw new Error(payload.error ?? 'Proxy did not return a token'); return token; }; /** * Callback que recibe oauth-server.ts cuando llega el código de autorización. * Delega al modo correcto; _config no se usa porque el modo ya está determinado. */ const exchangeOAuthCodeForToken = async ( code: string, redirectUri: string, _config: OAuthConfig, ): Promise => { const mode = getOAuthMode(); if (mode.type === 'dev') { return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret); } return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl); }; // ─── Servidor OAuth ──────────────────────────────────────────────────────────── const oauthServer = createOAuthServer({ provider: 'Challonge', callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH, authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT, scope: CHALLONGE_OAUTH_SCOPES, sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS, exchangeToken: exchangeOAuthCodeForToken, }); // ─── API de Challonge ────────────────────────────────────────────────────────── type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null; const parseJsonResponse = async (response: Response): Promise => { const rawBody = await response.text(); if (!rawBody) return null; try { return JSON.parse(rawBody) as unknown; } catch { return null; } }; const requestChallonge = async (path: string, token: string): Promise => { const requestUrl = `${CHALLONGE_API_BASE}${path}`; // ── Intento v2 (OAuth Bearer) ───────────────────────────────────────────── const v2Response = await fetch(requestUrl, { headers: { Accept: 'application/json', 'Content-Type': 'application/vnd.api+json', 'Authorization-Type': 'v2', Authorization: `Bearer ${token}`, }, }); const v2Payload = await parseJsonResponse(v2Response); if (v2Response.ok) { return v2Payload; } // ── Fallback v1 (API key personal pegada manualmente) ───────────────────── if (v2Response.status === 401) { const v1Response = await fetch(requestUrl, { headers: { Accept: 'application/json', 'Content-Type': 'application/vnd.api+json', 'Authorization-Type': 'v1', Authorization: token, }, }); const v1Payload = await parseJsonResponse(v1Response); if (v1Response.ok) { return v1Payload; } const v1Error = v1Payload as ChallongeErrorPayload; throw new Error( v1Error?.errors?.detail ?? v1Error?.error ?? `Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(), ); } // ── Otros errores v2 (4xx/5xx que no sean 401) ──────────────────────────── const v2Error = v2Payload as ChallongeErrorPayload; throw new Error( v2Error?.errors?.detail ?? v2Error?.error ?? `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(), ); }; // ─── Parsers de respuesta ────────────────────────────────────────────────────── const normalizeTournamentSlug = (value: string): string => { const trimmed = value.trim(); if (!trimmed) return ''; return trimmed .replace(/^https?:\/\/[^/]+\//i, '') .replace(/^tournaments\//i, '') .replace(/^\/+/, ''); }; const getNumberProp = (payload: Record, keys: string[]): number | null => { for (const key of keys) { const raw = payload[key]; if (typeof raw === 'number' && Number.isFinite(raw)) return raw; if (typeof raw === 'string') { const parsed = Number(raw); if (Number.isFinite(parsed)) return parsed; } } return null; }; const parseRecentTournaments = (payload: unknown): RecentTournament[] => { const rows: RecentTournament[] = []; const push = (candidate: Record) => { const attributes = typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record) : candidate; const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim(); const name = String(attributes.name ?? attributes.full_name ?? '').trim(); const slug = normalizeTournamentSlug( String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id), ); if (!id || !name || !slug) return; rows.push({ id, name, slug, startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']), endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']), }); }; if (Array.isArray(payload)) { for (const row of payload) { const wrapper = row as Record; const tournament = typeof wrapper.tournament === 'object' && wrapper.tournament !== null ? (wrapper.tournament as Record) : wrapper; push(tournament); } return rows; } if (typeof payload === 'object' && payload !== null) { const data = (payload as Record).data; if (Array.isArray(data)) { for (const row of data) { if (typeof row === 'object' && row !== null) { push(row as Record); } } } } return rows; }; const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => { const map = new Map(); const push = (candidate: Record) => { const attributes = typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record) : candidate; const id = String( candidate.id ?? attributes.id ?? attributes.participant_id ?? '', ).trim(); const rawDisplayName = String( attributes.display_name ?? attributes.name ?? attributes.username ?? attributes.gamer_tag ?? '', ).trim(); if (!id || !rawDisplayName) return; const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/; const pipeMatch = PIPE_PATTERN.exec(rawDisplayName); const teamFromName = pipeMatch ? pipeMatch[1].trim() : ''; const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName; const team = String(attributes.team_name ?? '').trim() || teamFromName; map.set(id, { id, gamertag, name: '', team, country: '', twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(), }); }; if (Array.isArray(payload)) { for (const row of payload) { const wrapper = row as Record; const participant = typeof wrapper.participant === 'object' && wrapper.participant !== null ? (wrapper.participant as Record) : wrapper; push(participant); } return Array.from(map.values()); } if (typeof payload === 'object' && payload !== null) { const data = (payload as Record).data; if (Array.isArray(data)) { for (const row of data) { if (typeof row === 'object' && row !== null) { push(row as Record); } } } } return Array.from(map.values()); }; // ─── Utilidades ──────────────────────────────────────────────────────────────── const getStringProp = (payload: unknown, key: string): string => { if (typeof payload !== 'object' || payload === null || !(key in payload)) return ''; const value = (payload as Record)[key]; return typeof value === 'string' ? value.trim() : String(value ?? '').trim(); }; const sendAck = (ack: unknown, error: string | null, response?: unknown) => { if (typeof ack === 'function') ack(error, response); }; // ─── Listeners de NodeCG ─────────────────────────────────────────────────────── nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { const mode = getOAuthMode(); let serverConfig: OAuthConfig; if (mode.type === 'dev') { serverConfig = { clientId: mode.clientId, callbackPort: mode.callbackPort, }; } else { // Modo proxy: el clientId viene del Worker (es público, no secreto) try { const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`); if (!res.ok) throw new Error(`Proxy responded with ${res.status}`); const data = await res.json() as { clientId?: string }; const clientId = String(data.clientId ?? '').trim(); if (!clientId) throw new Error('Proxy did not return a clientId'); serverConfig = { clientId, callbackPort: mode.callbackPort }; } catch (err) { sendAck( ack, err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy', ); return; } } try { await oauthServer.ensureServer(serverConfig); } catch (err) { sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server'); return; } sendAck(ack, null, oauthServer.createSession(serverConfig)); }); nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { const sessionId = getStringProp(payload, 'sessionId'); if (!sessionId) { sendAck(ack, 'Missing OAuth session id'); return; } const status = oauthServer.getSessionStatus(sessionId); if (!status) { sendAck(ack, 'OAuth session not found'); return; } sendAck(ack, null, status); }); nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => { const token = getStringProp(payload, 'token'); if (!token) { sendAck(ack, 'Missing Challonge API token'); return; } try { const raw = await requestChallonge('/tournaments.json', token); const tournaments = parseRecentTournaments(raw) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .slice(0, RECENT_TOURNAMENTS_LIMIT); sendAck(ack, null, tournaments); } catch (error) { sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments'); } }); nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => { const token = getStringProp(payload, 'token'); const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); if (!token) { sendAck(ack, 'Missing Challonge API token'); return; } if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } try { const raw = await requestChallonge( `/tournaments/${encodeURIComponent(slug)}/participants.json`, token, ); sendAck(ack, null, parseImportedPlayers(raw)); } catch (error) { sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players'); } });