mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-05 19:22:07 +00:00
feat: enhance OAuth configuration to support proxy mode and update related logic
This commit is contained in:
+10
-16
@@ -3,45 +3,39 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"exampleProperty": {
|
||||
"type": "string"
|
||||
"oauthProxyUrl": {
|
||||
"type": "string",
|
||||
"description": "Sobreescribe la URL base del proxy OAuth (por defecto usa la constante del código). Útil para staging o desarrollo del proxy."
|
||||
},
|
||||
"startggClientId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Client ID de tu OAuth app de start.gg"
|
||||
"description": "DEV ONLY: Client ID de tu propia OAuth app de start.gg. Si está presente junto a startggClientSecret, activa el modo dev (exchange directo, sin proxy)."
|
||||
},
|
||||
"startggClientSecret": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Client Secret de tu OAuth app de start.gg"
|
||||
"description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git."
|
||||
},
|
||||
"startggOAuthPort": {
|
||||
"type": "integer",
|
||||
"default": 34920,
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"description": "Puerto local para callback OAuth"
|
||||
"description": "Puerto local para el servidor de callback OAuth de start.gg."
|
||||
},
|
||||
"challongeClientId": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Client ID de tu OAuth app de Challonge"
|
||||
"description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev."
|
||||
},
|
||||
"challongeClientSecret": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Client Secret de tu OAuth app de Challonge"
|
||||
"description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git."
|
||||
},
|
||||
"challongeOAuthPort": {
|
||||
"type": "integer",
|
||||
"default": 34921,
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"description": "Puerto local para callback OAuth de Challonge"
|
||||
"description": "Puerto local para el servidor de callback OAuth de Challonge."
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"exampleProperty"
|
||||
]
|
||||
}
|
||||
|
||||
+105
-34
@@ -20,6 +20,14 @@ 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 {
|
||||
@@ -46,33 +54,53 @@ interface ImportedPlayer {
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||
// ─── 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.
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||
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;
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
return { clientId, clientSecret, callbackPort };
|
||||
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 };
|
||||
};
|
||||
|
||||
// ─── Intercambio de token ──────────────────────────────────────────────────────
|
||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
/** Modo dev: exchange directo con Challonge usando credenciales locales */
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
config: OAuthConfig,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
@@ -112,6 +140,50 @@ const exchangeOAuthCodeForToken = async (
|
||||
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({
|
||||
@@ -137,17 +209,6 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Realiza una petición autenticada a la API de Challonge.
|
||||
*
|
||||
* Intenta primero con OAuth v2 (Bearer token).
|
||||
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
|
||||
* En cualquier otro error no-2xx, lanza inmediatamente.
|
||||
*
|
||||
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
|
||||
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
|
||||
* nunca se alcanzaba tras el fallback v1.
|
||||
*/
|
||||
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||
|
||||
@@ -297,19 +358,13 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
||||
|
||||
if (!id || !rawDisplayName) return;
|
||||
|
||||
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
|
||||
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
|
||||
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||
|
||||
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||
|
||||
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
|
||||
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||
|
||||
// Challonge no expone un campo de nombre real separado del username/display_name.
|
||||
// Se deja vacío para no duplicar el gamertag en el campo name.
|
||||
map.set(id, {
|
||||
id,
|
||||
gamertag,
|
||||
@@ -361,24 +416,40 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const config = getOAuthConfig();
|
||||
if (!config) {
|
||||
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,
|
||||
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(config);
|
||||
await oauthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = oauthServer.createSession(config);
|
||||
sendAck(ack, null, session);
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
|
||||
+108
-19
@@ -1,6 +1,6 @@
|
||||
import { getData, type CountryRecord } from 'country-list';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
|
||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,14 @@ 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> {
|
||||
@@ -49,22 +57,41 @@ interface OAuthTokenResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||
// ─── 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.
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||
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;
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
// oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
|
||||
const proxyBaseUrl =
|
||||
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
|
||||
|
||||
return { clientId, clientSecret, callbackPort };
|
||||
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 };
|
||||
};
|
||||
|
||||
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
|
||||
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
@@ -75,16 +102,18 @@ const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenRes
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
/** Modo dev: exchange directo con start.gg usando credenciales locales */
|
||||
const exchangeCodeDirectly = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
config: OAuthConfig,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
@@ -116,13 +145,56 @@ const exchangeOAuthCodeForToken = async (
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`;
|
||||
|
||||
// Solo 404 justifica probar el siguiente endpoint
|
||||
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({
|
||||
@@ -203,24 +275,41 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const config = getOAuthConfig();
|
||||
if (!config) {
|
||||
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,
|
||||
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
|
||||
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(config);
|
||||
await oauthServer.ensureServer(serverConfig);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = oauthServer.createSession(config);
|
||||
sendAck(ack, null, session);
|
||||
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/** Solo necesario en modo dev (exchange directo con el proveedor).
|
||||
* En modo proxy el exchange lo hace el Worker y no necesita el secret. */
|
||||
clientSecret?: string;
|
||||
callbackPort: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user