diff --git a/docs/refactor/PHASE_1_SUMMARY.md b/docs/refactor/PHASE_1_SUMMARY.md new file mode 100644 index 0000000..dac3077 --- /dev/null +++ b/docs/refactor/PHASE_1_SUMMARY.md @@ -0,0 +1,20 @@ +# Summary: Phase 1 (Base Architecture) + +## Objetivos Completados +- **Reorganización Estructural**: Se movieron utilidades y tipos compartidos a `src/shared/utils/` y `src/shared/types/`. +- **Desacoplamiento del Backend**: Se eliminaron los monolitos `startgg.ts` y `challonge.ts` de `src/extension/`. +- **Creación de Capas**: + - `api/`: Llamadas aisladas de GraphQL y HTTP (`startgg.api.ts`, `challonge.api.ts`). + - `oauth/`: Lógica de autenticación OAuth manejada independientemente. + - `services/`: Lógica de dominio pura para transformar y parsear respuestas (ej. extraer `RecentTournament` y `ImportedPlayer`). + - `nodecg-bindings/`: Registros exclusivos de `nodecg.listenFor(...)` sin mezclar lógica de dominio. +- **Tipado Fuerte**: Se crearon interfaces centralizadas en `src/shared/types/domain.ts` asegurando tipos explícitos y la ausencia de `any`. +- **Consolidación**: Duplicidades como la resolución de códigos de país y parseo de strings (ej. `getStringProp`) se extrajeron a utilidades de `shared`. + +## Ajustes Técnicos Realizados +- El `tsconfig.extension.json` fue ajustado (`rootDir: "./src"`, `outDir: "./"`) para permitir que la compilación backend (`tsc`) incluya e integre los archivos de `src/shared/` de forma nativa sin romper la estructura requerida por NodeCG (que espera los archivos compilados del backend en el directorio raíz `extension/`). +- Actualización de todos los *imports* en vistas (`Players.vue`), *composables* (`useCountryFilter.ts`) y gráficos (`main.vue`). +- Compilación (`npm run build`) verificada y validada sin errores de TypeScript. + +## Siguientes Pasos Requeridos +- Avanzar a la **Fase 2**: Refactor del Estado del Dashboard (Stores), simplificando `store-sync.ts` e hidratando Pinia directamente desde los *Replicants*. diff --git a/shared/types/domain.js b/shared/types/domain.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/shared/types/domain.js @@ -0,0 +1 @@ +export {}; diff --git a/shared/utils/string.js b/shared/utils/string.js new file mode 100644 index 0000000..de4d656 --- /dev/null +++ b/shared/utils/string.js @@ -0,0 +1,28 @@ +export const getStringProp = (payload, key) => { + if (typeof payload !== 'object' || payload === null || !(key in payload)) + return ''; + const value = payload[key]; + return typeof value === 'string' ? value.trim() : String(value ?? '').trim(); +}; +export const getNumberProp = (payload, keys) => { + 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; +}; +export const normalizeTournamentSlug = (value) => { + const trimmed = value.trim(); + if (!trimmed) + return ''; + return trimmed + .replace(/^https?:\/\/[^/]+\//i, '') + .replace(/^tournaments\//i, '') + .replace(/^\/+/, ''); +}; diff --git a/src/dashboard/scoreko-dev/composables/useCountryFilter.ts b/src/dashboard/scoreko-dev/composables/useCountryFilter.ts index 0b865ea..af0708b 100644 --- a/src/dashboard/scoreko-dev/composables/useCountryFilter.ts +++ b/src/dashboard/scoreko-dev/composables/useCountryFilter.ts @@ -1,5 +1,5 @@ import { computed, ref, watch } from 'vue'; -import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; +import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries'; import { locale } from '../i18n'; /** diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index f8800f2..a24fb20 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -2,7 +2,7 @@ import { useHead } from '@unhead/vue'; import { useQuasar, type QTableColumn } from 'quasar'; import { computed, reactive, ref, watch } from 'vue'; -import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; +import { getCountryLabel, getCountryOptions } from '../../../shared/utils/countries'; import type { Schemas } from '../../../types'; import { useIntegration } from '../composables/useIntegration'; import { locale, t } from '../i18n'; diff --git a/src/extension/api/challonge.ts b/src/extension/api/challonge.ts new file mode 100644 index 0000000..6160b26 --- /dev/null +++ b/src/extension/api/challonge.ts @@ -0,0 +1,64 @@ +export const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; + +export 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; + } +}; + +export 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(), + ); +}; diff --git a/src/extension/api/startgg.ts b/src/extension/api/startgg.ts new file mode 100644 index 0000000..e16555e --- /dev/null +++ b/src/extension/api/startgg.ts @@ -0,0 +1,42 @@ +export const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha'; + +export interface StartGGGraphQLResponse { + data?: T; + errors?: Array<{ message?: string }>; +} + +export const requestStartGG = async ( + query: string, + variables: Record, + token: string, +): Promise => { + const response = await fetch(STARTGG_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim()); + } + + let payload: StartGGGraphQLResponse; + try { + payload = (await response.json()) as StartGGGraphQLResponse; + } catch { + throw new Error('Invalid JSON response from start.gg'); + } + + if (payload.errors?.length) { + throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error'); + } + + if (!payload.data) { + throw new Error('No data returned by start.gg'); + } + + return payload.data; +}; diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts deleted file mode 100644 index 1bcde62..0000000 --- a/src/extension/challonge.ts +++ /dev/null @@ -1,505 +0,0 @@ -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'); - } -}); diff --git a/src/extension/index.ts b/src/extension/index.ts index f56c18c..7112420 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -9,7 +9,7 @@ export default async (nodecg: NodeCGServerAPI) => { set(nodecg); // set nodecg "context" before anything else await import('./util/replicants.js'); // make sure replicants are set up await import('./example.js'); - await import('./startgg.js'); - await import('./challonge.js'); + await import('./nodecg-bindings/startgg.js'); + await import('./nodecg-bindings/challonge.js'); await import('./pack-manager.js'); }; diff --git a/src/extension/nodecg-bindings/challonge.ts b/src/extension/nodecg-bindings/challonge.ts new file mode 100644 index 0000000..9c0df52 --- /dev/null +++ b/src/extension/nodecg-bindings/challonge.ts @@ -0,0 +1,91 @@ +import { nodecg } from '../util/nodecg.js'; +import { getStringProp, normalizeTournamentSlug } from '../../shared/utils/string.js'; +import { challongeOAuthServer, getOAuthMode } from '../oauth/challonge.js'; +import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/challonge.js'; +import type { OAuthConfig } from '../util/oauth-server.js'; + +const sendAck = (ack: unknown, error: string | null, response?: unknown) => { + if (typeof ack === 'function') ack(error, response); +}; + +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 { + 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 challongeOAuthServer.ensureServer(serverConfig); + } catch (err) { + sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server'); + return; + } + + sendAck(ack, null, challongeOAuthServer.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 = challongeOAuthServer.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 tournaments = await fetchRecentTournaments(token); + 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 players = await fetchTournamentPlayers(slug, token); + sendAck(ack, null, players); + } catch (error) { + sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players'); + } +}); diff --git a/src/extension/nodecg-bindings/startgg.ts b/src/extension/nodecg-bindings/startgg.ts new file mode 100644 index 0000000..990f95f --- /dev/null +++ b/src/extension/nodecg-bindings/startgg.ts @@ -0,0 +1,91 @@ +import { nodecg } from '../util/nodecg.js'; +import { getStringProp } from '../../shared/utils/string.js'; +import { startggOAuthServer, getOAuthMode } from '../oauth/startgg.js'; +import { fetchRecentTournaments, fetchTournamentPlayers } from '../services/startgg.js'; +import type { OAuthConfig } from '../util/oauth-server.js'; + +const sendAck = (ack: unknown, error: string | null, response?: unknown) => { + if (typeof ack === 'function') ack(error, response); +}; + +nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { + const mode = getOAuthMode(); + let serverConfig: OAuthConfig; + + if (mode.type === 'dev') { + serverConfig = { + clientId: mode.clientId, + callbackPort: mode.callbackPort, + }; + } else { + try { + const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/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 startggOAuthServer.ensureServer(serverConfig); + } catch (err) { + sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server'); + return; + } + + sendAck(ack, null, startggOAuthServer.createSession(serverConfig)); +}); + +nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { + const sessionId = getStringProp(payload, 'sessionId'); + if (!sessionId) { + sendAck(ack, 'Missing OAuth session id'); + return; + } + + const status = startggOAuthServer.getSessionStatus(sessionId); + if (!status) { + sendAck(ack, 'OAuth session not found'); + return; + } + + sendAck(ack, null, status); +}); + +nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { + const token = getStringProp(payload, 'token'); + if (!token) { + sendAck(ack, 'Missing start.gg API token'); + return; + } + + try { + const tournaments = await fetchRecentTournaments(token); + sendAck(ack, null, tournaments); + } catch (error) { + sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments'); + } +}); + +nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => { + const token = getStringProp(payload, 'token'); + const slug = getStringProp(payload, 'slug'); + + if (!token) { sendAck(ack, 'Missing start.gg API token'); return; } + if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } + + try { + const players = await fetchTournamentPlayers(slug, token); + sendAck(ack, null, players); + } catch (error) { + sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players'); + } +}); diff --git a/src/extension/oauth/challonge.ts b/src/extension/oauth/challonge.ts new file mode 100644 index 0000000..27cc3f5 --- /dev/null +++ b/src/extension/oauth/challonge.ts @@ -0,0 +1,137 @@ +import { nodecg } from '../util/nodecg.js'; +import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js'; +import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js'; + +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(' '); +export const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; +const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; +const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; +const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev'; + +export 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 }; +}; + +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; +}; + +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; +}; + +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); +}; + +export const challongeOAuthServer = 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, +}); diff --git a/src/extension/oauth/startgg.ts b/src/extension/oauth/startgg.ts new file mode 100644 index 0000000..9cd81a1 --- /dev/null +++ b/src/extension/oauth/startgg.ts @@ -0,0 +1,139 @@ +import { nodecg } from '../util/nodecg.js'; +import { createOAuthServer, type OAuthConfig } from '../util/oauth-server.js'; +import type { OAuthMode, OAuthTokenResponse } from '../../shared/types/domain.js'; + +const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize'; +const STARTGG_OAUTH_TOKEN_ENDPOINTS = [ + 'https://www.start.gg/api/-/rest/oauth/access_token', + 'https://api.start.gg/oauth/access_token', +]; +const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager'; +export const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback'; +const STARTGG_OAUTH_DEFAULT_PORT = 34920; +const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; +const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev'; + +export const getOAuthMode = (): OAuthMode => { + const bundleConfig = nodecg.bundleConfig as Record; + const clientId = String(bundleConfig.startggClientId ?? '').trim(); + const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim(); + const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT); + const callbackPort = + Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT; + + const proxyBaseUrl = + String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL; + + if (clientId && clientSecret) { + nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)'); + return { type: 'dev', clientId, clientSecret, callbackPort }; + } + + nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`); + return { type: 'proxy', proxyBaseUrl, callbackPort }; +}; + +const parseOAuthTokenPayload = async (response: Response): Promise => { + const rawBody = await response.text(); + try { + return JSON.parse(rawBody) as OAuthTokenResponse; + } catch { + return { message: rawBody }; + } +}; + +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, + }); + + let lastError = 'Unknown OAuth token exchange error'; + + for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) { + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + const payload = await parseOAuthTokenPayload(response); + + if (response.ok) { + const token = String(payload.access_token ?? '').trim(); + if (token) return token; + lastError = + payload.error_description ?? + payload.error ?? + payload.message ?? + 'OAuth token response did not include an access token'; + continue; + } + + lastError = + payload.error_description ?? + payload.error ?? + payload.message ?? + `OAuth token request failed (${response.status})`; + + if (response.status !== 404) break; + } + + throw new Error(lastError); +}; + +const exchangeCodeViaProxy = async ( + code: string, + redirectUri: string, + proxyBaseUrl: string, +): Promise => { + const response = await fetch(`${proxyBaseUrl}/oauth/startgg/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; +}; + +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); +}; + +export const startggOAuthServer = createOAuthServer({ + provider: 'start.gg', + callbackPath: STARTGG_OAUTH_CALLBACK_PATH, + authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT, + scope: STARTGG_OAUTH_SCOPES, + sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS, + exchangeToken: exchangeOAuthCodeForToken, +}); diff --git a/src/extension/services/challonge.ts b/src/extension/services/challonge.ts new file mode 100644 index 0000000..ee2f54f --- /dev/null +++ b/src/extension/services/challonge.ts @@ -0,0 +1,138 @@ +import { requestChallonge } from '../api/challonge.js'; +import { normalizeTournamentSlug, getNumberProp } from '../../shared/utils/string.js'; +import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js'; + +const RECENT_TOURNAMENTS_LIMIT = 20; + +export 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; +}; + +export 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()); +}; + +export const fetchRecentTournaments = async (token: string): Promise => { + const raw = await requestChallonge('/tournaments.json', token); + return parseRecentTournaments(raw) + .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) + .slice(0, RECENT_TOURNAMENTS_LIMIT); +}; + +export const fetchTournamentPlayers = async (slug: string, token: string): Promise => { + const raw = await requestChallonge( + `/tournaments/${encodeURIComponent(slug)}/participants.json`, + token, + ); + return parseImportedPlayers(raw); +}; diff --git a/src/extension/services/startgg.ts b/src/extension/services/startgg.ts new file mode 100644 index 0000000..1ae35b5 --- /dev/null +++ b/src/extension/services/startgg.ts @@ -0,0 +1,115 @@ +import { getData, type CountryRecord } from 'country-list'; +import { requestStartGG } from '../api/startgg.js'; +import type { RecentTournament, ImportedPlayer } from '../../shared/types/domain.js'; + +const RECENT_TOURNAMENTS_LIMIT = 12; +const PARTICIPANTS_PAGE_SIZE = 120; + +const countries = getData(); +const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase())); +const countryByName = new Map( + countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]), +); + +export const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => { + const raw = (country ?? '').trim(); + if (!raw) return ''; + const upper = raw.toUpperCase(); + if (countryByCode.has(upper)) return upper; + return countryByName.get(raw.toLowerCase()) ?? ''; +}; + +export const fetchRecentTournaments = async (token: string): Promise => { + const query = ` + query RecentTournaments($perPage: Int!) { + currentUser { + tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) { + nodes { + id + name + slug + startAt + endAt + } + } + } + } + `; + + const data = await requestStartGG<{ + currentUser: { tournaments: { nodes: RecentTournament[] } } | null; + }>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token); + + return data.currentUser?.tournaments.nodes + .filter((item) => item.slug) + .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) + .map(({ id, name, slug, startAt, endAt }) => ({ id: String(id), name, slug, startAt, endAt })) ?? []; +}; + +export const fetchTournamentPlayers = async (slug: string, token: string): Promise => { + const query = ` + query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) { + tournament(slug: $slug) { + participants(query: { page: $page, perPage: $perPage }) { + pageInfo { + totalPages + } + nodes { + id + gamerTag + prefix + user { + location { + country + } + } + } + } + } + } + `; + + let currentPage = 1; + let totalPages = 1; + const playersMap = new Map(); + + while (currentPage <= totalPages) { + const data = await requestStartGG<{ + tournament: { + participants: { + pageInfo: { totalPages: number }; + nodes: Array<{ + id: number; + gamerTag: string | null; + prefix: string | null; + user: { location: { country: string | null } | null } | null; + }>; + }; + } | null; + }>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token); + + if (!data.tournament) throw new Error('Tournament not found'); + + const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages); + totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; + + for (const participant of data.tournament.participants.nodes) { + const playerId = String(participant.id); + const gamertag = (participant.gamerTag ?? '').trim(); + if (!gamertag) continue; + + playersMap.set(playerId, { + id: playerId, + gamertag, + name: gamertag, + team: (participant.prefix ?? '').trim(), + country: resolveCountryCodeFromStartGG(participant.user?.location?.country), + twitter: '', + }); + } + + currentPage += 1; + } + + return Array.from(playersMap.values()); +}; diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts deleted file mode 100644 index 1aec737..0000000 --- a/src/extension/startgg.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { getData, type CountryRecord } from 'country-list'; -import { nodecg } from './util/nodecg.js'; -import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js'; - -// ─── Constantes ──────────────────────────────────────────────────────────────── - -const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha'; -const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize'; -const STARTGG_OAUTH_TOKEN_ENDPOINTS = [ - 'https://www.start.gg/api/-/rest/oauth/access_token', - 'https://api.start.gg/oauth/access_token', -]; -const STARTGG_OAUTH_SCOPES = 'user.identity tournament.manager'; -const STARTGG_OAUTH_CALLBACK_PATH = '/startgg/callback'; -const STARTGG_OAUTH_DEFAULT_PORT = 34920; -const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; - -const RECENT_TOURNAMENTS_LIMIT = 12; -const PARTICIPANTS_PAGE_SIZE = 120; - -// ─── 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 StartGGGraphQLResponse { - data?: T; - errors?: Array<{ message?: string }>; -} - -interface RecentTournament { - id: number; - name: string; - slug: string; - startAt: number | null; - endAt: number | null; -} - -interface ImportedPlayer { - id: string; - gamertag: string; - name: string; - team: string; - country: string; - twitter: string; -} - -interface OAuthTokenResponse { - access_token?: string; - error?: string; - error_description?: string; - message?: string; -} - -// ─── Modo OAuth ──────────────────────────────────────────────────────────────── -// -// DEV: cfg/scoreko.json tiene startggClientId + startggClientSecret. -// El exchange se hace directamente contra start.gg. -// -// 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.startggClientId ?? '').trim(); - const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim(); - const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT); - const callbackPort = - Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT; - - // oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar - const proxyBaseUrl = - String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL; - - if (clientId && clientSecret) { - nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)'); - return { type: 'dev', clientId, clientSecret, callbackPort }; - } - - nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`); - return { type: 'proxy', proxyBaseUrl, callbackPort }; -}; - -// ─── Exchange de token ───────────────────────────────────────────────────────── - -const parseOAuthTokenPayload = async (response: Response): Promise => { - const rawBody = await response.text(); - try { - return JSON.parse(rawBody) as OAuthTokenResponse; - } catch { - return { message: rawBody }; - } -}; - -/** Modo dev: exchange directo con start.gg 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, - }); - - let lastError = 'Unknown OAuth token exchange error'; - - for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) { - const response = await fetch(tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }); - - const payload = await parseOAuthTokenPayload(response); - - if (response.ok) { - const token = String(payload.access_token ?? '').trim(); - if (token) return token; - lastError = - payload.error_description ?? - payload.error ?? - payload.message ?? - 'OAuth token response did not include an access token'; - continue; - } - - lastError = - payload.error_description ?? - payload.error ?? - payload.message ?? - `OAuth token request failed (${response.status})`; - - if (response.status !== 404) break; - } - - throw new Error(lastError); -}; - -/** 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/startgg/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: 'start.gg', - callbackPath: STARTGG_OAUTH_CALLBACK_PATH, - authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT, - scope: STARTGG_OAUTH_SCOPES, - sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS, - exchangeToken: exchangeOAuthCodeForToken, -}); - -// ─── GraphQL ─────────────────────────────────────────────────────────────────── - -const requestStartGG = async ( - query: string, - variables: Record, - token: string, -): Promise => { - const response = await fetch(STARTGG_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ query, variables }), - }); - - if (!response.ok) { - throw new Error(`start.gg responded with ${response.status} ${response.statusText}`.trim()); - } - - let payload: StartGGGraphQLResponse; - try { - payload = (await response.json()) as StartGGGraphQLResponse; - } catch { - throw new Error('Invalid JSON response from start.gg'); - } - - if (payload.errors?.length) { - throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error'); - } - - if (!payload.data) { - throw new Error('No data returned by start.gg'); - } - - return payload.data; -}; - -// ─── Resolución de países ────────────────────────────────────────────────────── - -const countries = getData(); -const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase())); -const countryByName = new Map( - countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]), -); - -const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => { - const raw = (country ?? '').trim(); - if (!raw) return ''; - const upper = raw.toUpperCase(); - if (countryByCode.has(upper)) return upper; - return countryByName.get(raw.toLowerCase()) ?? ''; -}; - -// ─── 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('startgg: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 (va en la URL del navegador), pero no lo queremos en el repo. - try { - const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/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('startgg: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('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { - const token = getStringProp(payload, 'token'); - if (!token) { - sendAck(ack, 'Missing start.gg API token'); - return; - } - - const query = ` - query RecentTournaments($perPage: Int!) { - currentUser { - tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) { - nodes { - id - name - slug - startAt - endAt - } - } - } - } - `; - - try { - const data = await requestStartGG<{ - currentUser: { tournaments: { nodes: RecentTournament[] } } | null; - }>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token); - - const tournaments = - data.currentUser?.tournaments.nodes - .filter((item) => item.slug) - .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) - .map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? []; - - sendAck(ack, null, tournaments); - } catch (error) { - sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments'); - } -}); - -nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => { - const token = getStringProp(payload, 'token'); - const slug = getStringProp(payload, 'slug'); - - if (!token) { sendAck(ack, 'Missing start.gg API token'); return; } - if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } - - const query = ` - query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) { - tournament(slug: $slug) { - participants(query: { page: $page, perPage: $perPage }) { - pageInfo { - totalPages - } - nodes { - id - gamerTag - prefix - user { - location { - country - } - } - } - } - } - } - `; - - try { - let currentPage = 1; - let totalPages = 1; - const playersMap = new Map(); - - while (currentPage <= totalPages) { - const data = await requestStartGG<{ - tournament: { - participants: { - pageInfo: { totalPages: number }; - nodes: Array<{ - id: number; - gamerTag: string | null; - prefix: string | null; - user: { location: { country: string | null } | null } | null; - }>; - }; - } | null; - }>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token); - - if (!data.tournament) throw new Error('Tournament not found'); - - const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages); - totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; - - for (const participant of data.tournament.participants.nodes) { - const playerId = String(participant.id); - const gamertag = (participant.gamerTag ?? '').trim(); - if (!gamertag) continue; - - playersMap.set(playerId, { - id: playerId, - gamertag, - name: gamertag, - team: (participant.prefix ?? '').trim(), - country: resolveCountryCodeFromStartGG(participant.user?.location?.country), - twitter: '', - }); - } - - currentPage += 1; - } - - sendAck(ack, null, Array.from(playersMap.values())); - } catch (error) { - sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players'); - } -}); diff --git a/src/graphics/scoreboard-2xko/main.vue b/src/graphics/scoreboard-2xko/main.vue index 804e078..42bfb50 100644 --- a/src/graphics/scoreboard-2xko/main.vue +++ b/src/graphics/scoreboard-2xko/main.vue @@ -2,7 +2,7 @@ import { useHead } from '@unhead/vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; -import { resolveCountryCode } from '../../shared/countries'; +import { resolveCountryCode } from '../../shared/utils/countries'; import { getCharactersByGame } from '../../shared/fighting-characters'; import type { Schemas } from '../../types'; diff --git a/src/graphics/scoreboard/main.vue b/src/graphics/scoreboard/main.vue index bffbc52..8188806 100644 --- a/src/graphics/scoreboard/main.vue +++ b/src/graphics/scoreboard/main.vue @@ -2,7 +2,7 @@ import { useHead } from '@unhead/vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; -import { resolveCountryCode } from '../../shared/countries'; +import { resolveCountryCode } from '../../shared/utils/countries'; import type { Schemas } from '../../types'; useHead({ title: 'Scoreboard' }); diff --git a/src/shared/types/domain.js b/src/shared/types/domain.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/shared/types/domain.js @@ -0,0 +1 @@ +export {}; diff --git a/src/shared/types/domain.ts b/src/shared/types/domain.ts new file mode 100644 index 0000000..311dfb5 --- /dev/null +++ b/src/shared/types/domain.ts @@ -0,0 +1,27 @@ +export interface OAuthTokenResponse { + access_token?: string; + error?: string; + error_description?: string; + message?: string; +} + +export interface RecentTournament { + id: string | number; + name: string; + slug: string; + startAt: number | null; + endAt: number | null; +} + +export interface ImportedPlayer { + id: string; + gamertag: string; + name: string; + team: string; + country: string; + twitter: string; +} + +export type OAuthMode = + | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } + | { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; diff --git a/src/shared/countries.ts b/src/shared/utils/countries.ts similarity index 100% rename from src/shared/countries.ts rename to src/shared/utils/countries.ts diff --git a/src/shared/utils/string.js b/src/shared/utils/string.js new file mode 100644 index 0000000..de4d656 --- /dev/null +++ b/src/shared/utils/string.js @@ -0,0 +1,28 @@ +export const getStringProp = (payload, key) => { + if (typeof payload !== 'object' || payload === null || !(key in payload)) + return ''; + const value = payload[key]; + return typeof value === 'string' ? value.trim() : String(value ?? '').trim(); +}; +export const getNumberProp = (payload, keys) => { + 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; +}; +export const normalizeTournamentSlug = (value) => { + const trimmed = value.trim(); + if (!trimmed) + return ''; + return trimmed + .replace(/^https?:\/\/[^/]+\//i, '') + .replace(/^tournaments\//i, '') + .replace(/^\/+/, ''); +}; diff --git a/src/shared/utils/string.ts b/src/shared/utils/string.ts new file mode 100644 index 0000000..1c297fb --- /dev/null +++ b/src/shared/utils/string.ts @@ -0,0 +1,26 @@ +export 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(); +}; + +export 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; +}; + +export const normalizeTournamentSlug = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed + .replace(/^https?:\/\/[^/]+\//i, '') + .replace(/^tournaments\//i, '') + .replace(/^\/+/, ''); +}; diff --git a/tsconfig.extension.json b/tsconfig.extension.json index b0557f4..cede471 100644 --- a/tsconfig.extension.json +++ b/tsconfig.extension.json @@ -7,8 +7,8 @@ "./node_modules/@types" ], "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.extension.tsbuildinfo", - "rootDir": "./src/extension", - "outDir": "./extension", + "rootDir": "./src", + "outDir": "./", "verbatimModuleSyntax": true, }, "include": [