2 Commits

Author SHA1 Message Date
Pandipipas 3a6289a2ea feat: implement start.gg OAuth integration and services
- Added start.gg OAuth server and session management in startgg.ts
- Implemented functions to fetch recent tournaments and tournament players from start.gg
- Created utility functions for string and country code handling
- Introduced Challonge OAuth server and services for tournament data fetching
- Refactored shared types and utility functions for better organization
- Updated scoreboard graphics to use new country resolution utilities
- Removed legacy startgg.ts file to streamline codebase
2026-06-04 17:42:44 +02:00
Pandipipas 71c18b479b feat: add architectural documentation for refactor process, including audit, rules, migration plan, session handoff, and target architecture 2026-06-04 17:07:01 +02:00
29 changed files with 1177 additions and 960 deletions
+60
View File
@@ -0,0 +1,60 @@
# Scoreko-dev: Auditoría de Arquitectura
Este documento consolida el análisis de la arquitectura actual y el diagnóstico de los problemas encontrados, sirviendo como punto de partida para el refactor.
## Análisis de la Estructura Actual
El proyecto está estructurado utilizando `pnpm workspaces` (`nodecg`, `shared` y el directorio raíz). El código fuente principal reside en `src/`, y se compila a `extension/`, `dashboard/` y `graphics/`.
### Distribución de Carpetas
| Carpeta | Descripción |
| :--- | :--- |
| `src/dashboard/scoreko-dev/` | Contiene la UI del dashboard construida con Vue 3, Quasar y Pinia. |
| `src/graphics/` | Contiene los overlays (scoreboard, commentary, scoreboard-2xko) en Vue 3. |
| `src/extension/` | Backend NodeCG. Contiene la integración con start.gg, Challonge y la gestión de packs. |
| `src/shared/` | Lógica o utilidades compartidas (por ejemplo, lista de países). |
| `src/browser_shared/` | Replicantes expuestos a las apps Vue. |
### Flujo de Datos y Estado (Stores y Replicants)
- **Dashboard (Pinia + Replicants)**: Se usan stores de Pinia (`scoreboard.ts`, `players.ts`, `commentary.ts`) para manejar el estado en el dashboard. Existe un mecanismo de sincronización `store-sync.ts` que ata los ref de Vue (stores) a los Replicants de NodeCG, con un fallback en `localStorage`.
- **Graphics**: Los componentes gráficos (ej. `src/graphics/scoreboard/main.vue`) importan directamente los replicants de `browser_shared/replicants.ts` y usan `watch` para reaccionar a cambios. No parecen usar Pinia, sino estado reactivo local (`ref`, `computed`).
### Componentes Monolíticos
Existen componentes excesivamente grandes que mezclan responsabilidades:
- **`src/dashboard/scoreko-dev/views/Players.vue`** (>680 líneas): Mezcla presentación, diálogos de UI, validaciones de formulario, llamadas a composables de integración (`useIntegration`), transformación de datos, renderizado manual de iconos SVG hardcodeados y lógica de exportación/importación JSON.
- **`src/graphics/scoreboard/main.vue`** (>690 líneas): Mezcla la UI del marcador, cálculos de dimensiones de fuentes (`fitText`), timeouts manuales para animaciones, carga asíncrona de SVG de banderas usando importación dinámica de Vite, bindings a NodeCG y redirecciones de URL basadas en configuraciones gráficas.
### Acoplamientos y Efectos Secundarios
- En el dashboard, la lógica de integración de torneos (start.gg/Challonge) está fuertemente acoplada a la vista de jugadores mediante composables y callbacks, con SVGs directamente embebidos en el template.
- En el backend (`src/extension/startgg.ts`), hay mezcla de servidor OAuth HTTP puro, lógica de peticiones GraphQL con strings literales gigantes, parsing de errores y endpoints de NodeCG (`nodecg.listenFor`), todo en el mismo archivo (>440 líneas).
---
## Diagnóstico
### Problemas Críticos
1. **Lógica de negocio acoplada a la UI**: Componentes como `Players.vue` y `main.vue` del scoreboard saben demasiado. Tienen lógica de red, cálculos de DOM, manejo de timeouts y manipulación de datos en crudo en lugar de delegar a stores o servicios.
2. **"Vibe Coding" y AI Slop**: Hay parches evidentes como la inclusión manual de SVGs inmensos inline en los templates, y utilidades infladas (cálculos rudimentarios de `fitText` en los overlays en lugar de usar CSS moderno o directivas reutilizables).
3. **Estado implícito y dependencias circulares potenciales**: El sistema de `store-sync.ts` que sincroniza Pinia <-> LocalStorage <-> NodeCG Replicants es frágil, creando condiciones de carrera sobre cuál es la "fuente de la verdad".
4. **Falta de abstracción en el Backend NodeCG**: Los archivos de `extension/` son scripts procesales en lugar de arquitecturas separadas.
### Impacto a Medio y Largo Plazo
- **Mantenibilidad Reducida**: Agregar nuevas integraciones (ej. smash.gg, Toornament) requerirá copiar/pegar más bloques monolíticos y añadir más SVGs hardcodeados.
- **Riesgo de Regresiones**: Modificar animaciones del scoreboard puede romper el cálculo del tamaño de fuente o la lógica de banderas, debido al acoplamiento.
- **Developer Experience (DX) Pobre**: La curva de aprendizaje es alta. Entender cómo fluye un cambio de score desde el dashboard hasta el overlay se vuelve muy complejo.
### Estrategia de Resolución
- **Reorganización**: Mantener la estructura base (`src/dashboard`, `src/graphics`, `src/extension`), pero crear subcarpetas por dominio/feature en el backend y separación estricta en el frontend.
- **Refactor**: Simplificar stores (eliminar puentes complejos a NodeCG), extracción de composables puros en el dashboard, separación de UI *Dumb* vs UI *Smart*.
- **Reescritura controlada**:
- `Players.vue`: Dividir drásticamente.
- `main.vue` (scoreboard): Extraer lógica de flags, animaciones y `fitText`.
- `startgg.ts` / `challonge.ts`: Adoptar un patrón Service/Repository.
+37
View File
@@ -0,0 +1,37 @@
# Reglas Arquitectónicas de Implementación
> [!IMPORTANT]
> Estas reglas son estrictas y obligatorias. Se deben aplicar sin excepción durante toda la fase de refactorización y en el futuro desarrollo.
1. **NO `any`, NO IGNORES**
- Prohibido el uso de `any`, `@ts-ignore` o casteos forzados ciegos (`as unknown as Tipo`). Todo debe tener tipado fuerte en TypeScript.
2. **CERO LÓGICA DE NEGOCIO EN COMPONENTES**
- Los componentes de Vue (`.vue`) no deben tener llamadas `fetch`, lógica compleja de parseo, o cálculos pesados.
- Su sección `<script>` debe limitarse exclusivamente a invocar *composables* o *stores*, y exponer datos al `template`.
3. **COMPONENTES PEQUEÑOS Y "DUMB"**
- Si el template de un componente supera las 100 líneas, es un síntoma de que debe subdividirse.
- Fomentar la creación de componentes presentacionales ("dumb components") que reciben datos únicamente mediante `props` y se comunican hacia arriba mediante `emits`.
4. **FUNCIONES PURAS (PURE FUNCTIONS) PRIMERO**
- Cualquier transformación de datos (ej. extraer *gamertags*, limpiar strings, dar formato a números) debe residir en una función pura y testeable, fuera del ecosistema Vue y de NodeCG.
5. **SIN WRAPPERS INÚTILES**
- Evitar crear *composables* simplemente por envolver una o dos líneas de código si no aportan verdadera semántica o abstracción de dominio.
6. **USO DE PATRONES ESTÁNDAR VUE 3**
- Utilizar exclusivamente convenciones estándar de Vue 3: Composition API pura, `<script setup>` y el ecosistema reactivo estándar de Pinia.
- Nada de patrones híbridos ni inventados.
7. **BORRAR SOBRE CONSERVAR (Limpieza de AI Slop)**
- Si se detecta código redundante o inútil (ej. código "AI slop" o enormes SVGs hardcodeados en HTML para iconos simples), la prioridad es eliminarlo.
- Sustituir por alternativas limpias y mantenibles (como usar iconos vectoriales de Quasar u hojas de estilo puras).
8. **EFECTOS SECUNDARIOS (SIDE EFFECTS) CONTROLADOS**
- El uso de `watch` debe ser el mínimo indispensable.
- Siempre que se deba reaccionar a un cambio, preferir flujos de datos unidireccionales (ej. variables calculadas mediante `computed`) en lugar de mutar un estado local desde un watcher reactivo.
9. **REESCRITURA SÍ, PARCHEO NO**
- Las zonas marcadas para reescritura en el plan (ej. `Players.vue` y `graphics/main.vue`) deben ser rehechas lógicamente.
- El objetivo es mantener el output visual o funcional intacto pero desechando la estructura legacy interna. No se aceptan "parches temporales" en estas áreas clave.
+47
View File
@@ -0,0 +1,47 @@
# Plan de Migración
> [!WARNING]
> Este plan está diseñado para evitar regresiones y mantener un estado "compilable" en todo momento. Se debe ejecutar estrictamente de backend a frontend, y de lógica pura a UI.
## Paso 1: Estabilización del Shared y Tipos
- **Acciones**:
- Mover utilidades genéricas (como la lista de `countries`, funciones de manipulación de strings) a `src/shared/utils/`.
- Refinar y consolidar los tipos en `src/shared/types/` para que representen el dominio real.
- Eliminar tipos parciales o duplicados dispersos en el código.
- **Riesgo**: Bajo. Gran parte es movimiento y ajuste de imports.
## Paso 2: Refactor del Backend (Extension)
- **Acciones**:
- **Reescritura controlada de integraciones**: Dividir `startgg.ts` en:
- `services/startgg.ts` (lógica de negocio y transformaciones).
- `api/startgg.ts` (GraphQL / HTTP requests).
- `oauth/startgg.ts` (flujo OAuth).
- `nodecg-bindings/startgg.ts` (vinculación exclusiva de `nodecg.listenFor`).
- Repetir la misma división para `challonge.ts`.
- Mover cualquier otra utilidad de backend a `extension/utils/`.
- **Riesgo**: Moderado. Requiere trasladar código con cuidado para no romper las firmas de los métodos.
## Paso 3: Refactor del Estado del Dashboard (Stores)
- **Acciones**:
- Limpiar `store-sync.ts`. Reducir la complejidad y sobre-ingeniería generada por el uso del `localStorage`.
- Asegurar que Pinia dependa directa y limpiamente de los Replicantes como fuente de datos real.
- Garantizar que los stores exporten acciones limpias, evitando que el estado interno se mute manualmente desde los componentes de la vista.
- **Riesgo**: Medio. Afecta el flujo de reactividad base de la UI.
## Paso 4: Modularización de los Componentes Grandes (Dashboard)
- **Acciones**:
- **Reescritura/División controlada de `Players.vue`**:
- Extraer modales a componentes independientes (ej. `ImportDialog.vue`, `PlayerEditDialog.vue`).
- Extraer los selectores de integración a componentes puros (`StartGGPanel.vue`, `ChallongePanel.vue`).
- Reemplazar los SVGs "hardcodeados" en el template por un componente dedicado o usar la librería de iconos de Quasar.
- Extraer partes de vistas monolíticas (`PlayerSidePanel.vue`) en sub-componentes especializados.
- **Riesgo**: Medio. Afecta directamente a la UI. Es crítico asegurar que los eventos (`emits`) se propagen y conecten correctamente.
## Paso 5: Refactor de los Gráficos (Scoreboard)
- **Acciones**:
- **Reescritura controlada de `main.vue`**:
- Extraer la lógica de ajuste de fuente a un archivo dedicado, ya sea como directiva o función: `graphics/shared/utils/fitText.ts` (o `v-fit-text`).
- Extraer la lógica de resolución de banderas (flags) y su caché a un composable dedicado: `useFlag(countryCode)`.
- Extraer el control de animaciones y timeouts a `useScoreAnimation(scoreRef)`.
- Dividir el DOM en componentes claros: `<BackgroundPanel>`, `<PlayerInfo side="left">`, `<ScoreDisplay>`, orquestados desde `App.vue` (o `main.vue` simplificado).
- **Riesgo**: Alto. Las animaciones y cálculos visuales de DOM son delicados. El objetivo visual final debe ser idéntico, y el comportamiento ante cambios de Replicants debe mantenerse exacto.
+20
View File
@@ -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*.
+24
View File
@@ -0,0 +1,24 @@
# Session Handoff: Refactor NodeCG Scoreboard
Este documento sirve como registro de estado y transferencia de contexto para cualquier agente o desarrollador en futuras sesiones de trabajo.
## Estado Actual
- **Fase de Análisis y Diagnóstico:** Completada.
- **Fase de Definición de Arquitectura y Reglas:** Completada.
- **Documentación:** Generada y almacenada en `docs/refactor/`.
## Fuente de la Verdad (Source of Truth)
Para cualquier duda, decisión arquitectónica, o estructuración de código durante el refactor, consulta **EXCLUSIVAMENTE** el documento:
- [TARGET_ARCHITECTURE.md](./TARGET_ARCHITECTURE.md)
Además, asegúrate de seguir las directrices dictadas en:
- [ARCHITECTURE_RULES.md](./ARCHITECTURE_RULES.md)
## Próximos Pasos (Next Actions)
La próxima sesión debe comenzar con la ejecución del `MIGRATION_PLAN.md`, ejecutando los pasos de forma estrictamente secuencial:
1. **Revisar [MIGRATION_PLAN.md](./MIGRATION_PLAN.md) -> Paso 1.**
2. Comenzar la creación/movimiento de utilidades compartidas hacia `src/shared/`.
3. Proceder únicamente al Paso 2 cuando el Paso 1 compile perfectamente y no existan errores de tipado.
No te desvíes de la secuencia. Evita realizar cambios no relacionados o abordar los gráficos antes de tener estabilizado el backend (`extension/`) y el core compartido.
+53
View File
@@ -0,0 +1,53 @@
# Arquitectura Objetivo (Target Architecture)
> [!IMPORTANT]
> Este documento sirve como la única fuente de la verdad para el diseño del sistema durante y después del refactor.
## Estructura de Capas
La aplicación se dividirá estrictamente en las siguientes capas lógicas:
1. **Capa NodeCG (Bindings)**: Archivos cuya *única* responsabilidad es declarar `nodecg.listenFor` (backend) o importar `nodecg.Replicant` (frontend).
2. **Capa de Estado (Stores)**: Pinia será la única fuente de la verdad para la UI. Los stores se hidratarán de los replicants sin lógicas cruzadas complejas de `localStorage`.
3. **Capa de Lógica Pura (Services/Domain)**: Funciones en TypeScript puro sin dependencias de Vue ni de NodeCG que transforman, formatean o calculan datos.
4. **Capa de UI (Dumb Components)**: Componentes Vue puramente presentacionales que solo reciben `props` y emiten `events`.
5. **Capa de Orquestación (Smart Components / Composables)**: Vistas y composables que conectan los Stores y/o NodeCG con los Dumb Components.
## Estructura de Carpetas Propuesta
```text
src/
├── browser_shared/
│ ├── replicants.ts # Declaraciones puras
│ └── useReplicant.ts # (NUEVO) Composable unificado para hidratar Vue desde NodeCG
├── shared/
│ ├── types/ # Tipos estrictos compartidos
│ └── utils/ # Helpers de dominio puros (ej. formateo)
├── extension/ # Backend NodeCG
│ ├── index.ts # Entry point
│ ├── nodecg-bindings/ # Registro exclusivo de nodecg.listenFor()
│ ├── services/ # Lógica de negocio pura (StartGGService, ChallongeService)
│ ├── api/ # Llamadas HTTP/GraphQL
│ └── oauth/ # Manejo de flujos de autenticación OAuth aislados
├── dashboard/
│ └── scoreko-dev/
│ ├── components/ # UI (Small, dumb components)
│ ├── composables/ # Lógica orquestada y reutilizable
│ ├── features/ # (NUEVO) Dominio agrupado (ej. /players, /integrations)
│ ├── stores/ # Pinia stores (Fuente de la verdad UI)
│ └── views/ # Smart components (Orquestadores)
└── graphics/
├── shared/ # (NUEVO) Componentes y composables compartidos entre gráficos
│ ├── directives/ # ej. v-fit-text
│ └── composables/ # ej. useScoreAnimation, useFlags
├── scoreboard/
│ ├── components/ # Componentes segregados (PlayerName.vue, Score.vue, BackgroundPanel.vue)
│ └── App.vue # Orquestador principal del scoreboard
└── scoreboard-2xko/
```
## Reglas Arquitectónicas de Diseño
- **Domain Driven**: El backend y el dashboard se organizarán por dominio o feature (`players`, `scoreboard`, `integrations`) donde sea posible.
- **Aislamiento de NodeCG**: En el backend, toda lógica debe vivir en clases o funciones de servicio que reciben datos y devuelven promesas. La integración con la API de NodeCG solo llama a esos servicios; no se debe inyectar NodeCG en los servicios si no es estrictamente necesario.
- **Tipado Estricto**: Todo el output de GraphQL/HTTP debe validarse/parsearse a un tipo de dominio lo antes posible en la capa de API.
+1
View File
@@ -0,0 +1 @@
export {};
+28
View File
@@ -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(/^\/+/, '');
};
@@ -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';
/**
+1 -1
View File
@@ -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';
+64
View File
@@ -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<unknown> => {
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<unknown> => {
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(),
);
};
+42
View File
@@ -0,0 +1,42 @@
export const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
export interface StartGGGraphQLResponse<T> {
data?: T;
errors?: Array<{ message?: string }>;
}
export const requestStartGG = async <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
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<T>;
try {
payload = (await response.json()) as StartGGGraphQLResponse<T>;
} 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;
};
-505
View File
@@ -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<string, unknown>;
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<string> => {
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<string> => {
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<string> => {
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<unknown> => {
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<unknown> => {
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<string, unknown>, 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<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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<string, unknown>;
const tournament =
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
? (wrapper.tournament as Record<string, unknown>)
: wrapper;
push(tournament);
}
return rows;
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
return rows;
};
const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
const map = new Map<string, ImportedPlayer>();
const push = (candidate: Record<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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<string, unknown>;
const participant =
typeof wrapper.participant === 'object' && wrapper.participant !== null
? (wrapper.participant as Record<string, unknown>)
: wrapper;
push(participant);
}
return Array.from(map.values());
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
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<string, unknown>)[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');
}
});
+2 -2
View File
@@ -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');
};
@@ -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');
}
});
+91
View File
@@ -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');
}
});
+137
View File
@@ -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<string, unknown>;
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<string> => {
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<string> => {
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<string> => {
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,
});
+139
View File
@@ -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<string, unknown>;
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<OAuthTokenResponse> => {
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<string> => {
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<string> => {
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<string> => {
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,
});
+138
View File
@@ -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<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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<string, unknown>;
const tournament =
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
? (wrapper.tournament as Record<string, unknown>)
: wrapper;
push(tournament);
}
return rows;
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
return rows;
};
export const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
const map = new Map<string, ImportedPlayer>();
const push = (candidate: Record<string, unknown>) => {
const attributes =
typeof candidate.attributes === 'object' && candidate.attributes !== null
? (candidate.attributes as Record<string, unknown>)
: 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<string, unknown>;
const participant =
typeof wrapper.participant === 'object' && wrapper.participant !== null
? (wrapper.participant as Record<string, unknown>)
: wrapper;
push(participant);
}
return Array.from(map.values());
}
if (typeof payload === 'object' && payload !== null) {
const data = (payload as Record<string, unknown>).data;
if (Array.isArray(data)) {
for (const row of data) {
if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>);
}
}
}
}
return Array.from(map.values());
};
export const fetchRecentTournaments = async (token: string): Promise<RecentTournament[]> => {
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<ImportedPlayer[]> => {
const raw = await requestChallonge(
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
token,
);
return parseImportedPlayers(raw);
};
+115
View File
@@ -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<RecentTournament[]> => {
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<ImportedPlayer[]> => {
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<string, ImportedPlayer>();
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());
};
-447
View File
@@ -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<T> {
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<string, unknown>;
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<OAuthTokenResponse> => {
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<string> => {
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<string> => {
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<string> => {
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 <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
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<T>;
try {
payload = (await response.json()) as StartGGGraphQLResponse<T>;
} 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<string, unknown>)[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<string, ImportedPlayer>();
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');
}
});
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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' });
+1
View File
@@ -0,0 +1 @@
export {};
+27
View File
@@ -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 };
+28
View File
@@ -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(/^\/+/, '');
};
+26
View File
@@ -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<string, unknown>)[key];
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
};
export const getNumberProp = (payload: Record<string, unknown>, 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(/^\/+/, '');
};
+2 -2
View File
@@ -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": [