- Updated OAuth endpoints for Challonge and Discord in platforms configuration. - Implemented session and CSRF cookie initialization in main application entry. - Enhanced Challonge API client to avoid sending sensitive API keys from the browser. - Modified tournament querying to handle new state definitions and improved error handling. - Updated UI components to reflect server-side storage of authentication tokens. - Improved user experience in API Key Manager and Authentication Hub with clearer messaging. - Refactored client credentials management to support asynchronous operations. - Adjusted API client tests to validate new request configurations. - Updated Vite configuration to support session and CSRF handling through proxies.
570 lines
16 KiB
JavaScript
570 lines
16 KiB
JavaScript
/**
|
|
* Challonge API v2.1 Service
|
|
* Client for interacting with Challonge API v2.1 (current version)
|
|
*
|
|
* Features:
|
|
* - OAuth 2.0 support (Bearer tokens)
|
|
* - API v1 key compatibility
|
|
* - JSON:API specification compliant
|
|
* - Tournament, Participant, Match, Race endpoints
|
|
* - Community and Application scoping
|
|
*
|
|
* @see https://challonge.apidog.io/getting-started-1726706m0
|
|
* @see https://challonge.apidog.io/llms.txt
|
|
*/
|
|
|
|
/**
|
|
* Get the appropriate base URL based on environment
|
|
* Always use nginx proxy to avoid CORS issues
|
|
*/
|
|
function getBaseURL() {
|
|
return '/api/challonge/v2.1';
|
|
}
|
|
|
|
/**
|
|
* Authentication types for Challonge API v2.1
|
|
*/
|
|
export const AuthType = {
|
|
OAUTH: 'v2', // Bearer token
|
|
API_KEY: 'v1' // Legacy API key
|
|
};
|
|
|
|
/**
|
|
* Resource scoping options
|
|
*/
|
|
export const ScopeType = {
|
|
USER: 'user', // /v2.1/tournaments (default)
|
|
COMMUNITY: 'community', // /v2.1/communities/{id}/tournaments
|
|
APPLICATION: 'app' // /v2.1/application/tournaments
|
|
};
|
|
|
|
/**
|
|
* Create Challonge API v2.1 client
|
|
*
|
|
* @param {Object} auth - Authentication configuration
|
|
* @param {string} auth.token - OAuth Bearer token or API v1 key
|
|
* @param {string} auth.type - AuthType.OAUTH or AuthType.API_KEY (default: API_KEY)
|
|
* @param {Object} options - Client options
|
|
* @param {string} options.communityId - Default community ID for scoping
|
|
* @param {boolean} options.debug - Enable debug logging
|
|
* @returns {Object} API client with methods
|
|
*/
|
|
export function createChallongeV2Client(auth, options = {}) {
|
|
const { token, type = AuthType.API_KEY } = auth || {};
|
|
const { communityId: defaultCommunityId, debug = false } = options;
|
|
const baseURL = getBaseURL();
|
|
|
|
// Request tracking for debug mode
|
|
let requestCount = 0;
|
|
|
|
/**
|
|
* Make API request with JSON:API format
|
|
*/
|
|
async function makeRequest(endpoint, options = {}) {
|
|
const {
|
|
method = 'GET',
|
|
body,
|
|
params = {},
|
|
headers = {},
|
|
communityId = defaultCommunityId,
|
|
scopeType = ScopeType.USER
|
|
} = options;
|
|
|
|
const startTime = performance.now();
|
|
requestCount++;
|
|
|
|
// Build URL with scoping
|
|
let url = baseURL;
|
|
|
|
if (scopeType === ScopeType.COMMUNITY && communityId) {
|
|
url += `/communities/${communityId}`;
|
|
} else if (scopeType === ScopeType.APPLICATION) {
|
|
url += '/application';
|
|
}
|
|
|
|
url += `/${endpoint}`;
|
|
|
|
// Add query parameters
|
|
const urlObj = new URL(url, window.location.origin);
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
urlObj.searchParams.append(key, value);
|
|
}
|
|
});
|
|
|
|
// Add communityId as query param if not in path
|
|
if (communityId && scopeType === ScopeType.USER) {
|
|
urlObj.searchParams.append('community_id', communityId);
|
|
}
|
|
|
|
// Prepare headers (JSON:API required format)
|
|
const requestHeaders = {
|
|
'Content-Type': 'application/vnd.api+json',
|
|
Accept: 'application/json',
|
|
'Authorization-Type': type,
|
|
...headers
|
|
};
|
|
|
|
// No-split-brain: never send Challonge tokens from the browser.
|
|
// Backend proxy derives auth from the per-session SID cookie and the Authorization-Type hint.
|
|
// (Token is intentionally ignored here.)
|
|
|
|
const fetchOptions = {
|
|
method,
|
|
headers: requestHeaders,
|
|
credentials: 'include',
|
|
cache: 'no-store'
|
|
};
|
|
|
|
if (body && method !== 'GET') {
|
|
// Wrap in JSON:API format if not already wrapped
|
|
const jsonApiBody = body.data ? body : { data: body };
|
|
fetchOptions.body = JSON.stringify(jsonApiBody);
|
|
}
|
|
|
|
if (debug) {
|
|
console.log(
|
|
`[Challonge v2.1 Request #${requestCount}]`,
|
|
method,
|
|
urlObj.toString()
|
|
);
|
|
if (body) console.log('Body:', fetchOptions.body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(urlObj.toString(), fetchOptions);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Handle 204 No Content
|
|
if (response.status === 204) {
|
|
if (debug)
|
|
console.log(
|
|
`[Challonge v2.1 Response] 204 No Content (${duration.toFixed(0)}ms)`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Parse response body (prefer JSON when declared)
|
|
const contentType = response.headers.get('content-type') || '';
|
|
let data;
|
|
try {
|
|
if (contentType.includes('application/json')) {
|
|
data = await response.json();
|
|
} else {
|
|
const text = await response.text();
|
|
// Best-effort: if it's actually JSON but wrong content-type, parse it.
|
|
data = text;
|
|
if (text && (text.startsWith('{') || text.startsWith('['))) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
// keep as text
|
|
}
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
if (debug) {
|
|
console.error('[Challonge v2.1 JSON Parse Error]', parseError);
|
|
}
|
|
const error = new Error(`HTTP ${response.status}: Failed to parse response`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
if (debug) {
|
|
console.log(
|
|
`[Challonge v2.1 Response] ${response.status} (${duration.toFixed(0)}ms)`,
|
|
data
|
|
);
|
|
}
|
|
|
|
// Handle JSON:API errors
|
|
if (!response.ok) {
|
|
if (data.errors && Array.isArray(data.errors)) {
|
|
const errorDetails = data.errors.map(e => ({
|
|
status: e.status || response.status,
|
|
message: e.detail || e.title || response.statusText,
|
|
field: e.source?.pointer
|
|
}));
|
|
|
|
const errorMessage = errorDetails
|
|
.map(
|
|
e => `${e.status}: ${e.message}${e.field ? ` (${e.field})` : ''}`
|
|
)
|
|
.join('\n');
|
|
|
|
const error = new Error(errorMessage);
|
|
error.status = response.status;
|
|
error.errors = errorDetails;
|
|
error.response = data;
|
|
throw error;
|
|
}
|
|
// Handle non-JSON:API error format
|
|
const messageFromBody =
|
|
typeof data === 'string'
|
|
? data
|
|
: data?.error || data?.message || response.statusText;
|
|
|
|
const fallbackMessage = response.statusText || 'Request failed';
|
|
const finalMessage =
|
|
typeof messageFromBody === 'string' && messageFromBody.trim().length === 0
|
|
? fallbackMessage
|
|
: messageFromBody || fallbackMessage;
|
|
|
|
const error = new Error(`HTTP ${response.status}: ${finalMessage}`);
|
|
error.status = response.status;
|
|
error.response = data;
|
|
throw error;
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
if (debug) {
|
|
console.error('[Challonge v2.1 Error]', error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to unwrap JSON:API response and normalize structure
|
|
*/
|
|
function unwrapResponse(response) {
|
|
if (!response) return null;
|
|
|
|
// If response has data property, it's JSON:API format
|
|
if (response.data) {
|
|
const data = response.data;
|
|
|
|
// Handle array of resources
|
|
if (Array.isArray(data)) {
|
|
return data.map(item => normalizeResource(item));
|
|
}
|
|
|
|
// Handle single resource
|
|
return normalizeResource(data);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Normalize JSON:API resource to flat structure
|
|
*/
|
|
function normalizeResource(resource) {
|
|
if (!resource || !resource.attributes) return resource;
|
|
|
|
return {
|
|
id: resource.id,
|
|
type: resource.type,
|
|
...resource.attributes,
|
|
relationships: resource.relationships,
|
|
links: resource.links
|
|
};
|
|
}
|
|
|
|
// ==================== Tournament Methods ====================
|
|
const tournaments = {
|
|
/**
|
|
* List tournaments
|
|
* @param {Object} options - Query options
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
list: async (options = {}) => {
|
|
const { communityId, scopeType, ...params } = options;
|
|
const response = await makeRequest('tournaments.json', {
|
|
params,
|
|
communityId,
|
|
scopeType
|
|
});
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
/**
|
|
* Get tournament details
|
|
* @param {string} id - Tournament ID or URL
|
|
* @param {Object} options - Options
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
get: async (id, options = {}) => {
|
|
const { communityId, scopeType, ifNoneMatch } = options;
|
|
const response = await makeRequest(`tournaments/${id}.json`, {
|
|
communityId,
|
|
scopeType,
|
|
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
|
|
});
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
/**
|
|
* Create tournament
|
|
* @param {Object} data - Tournament data
|
|
* @param {Object} options - Options
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
create: async (data, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest('tournaments.json', {
|
|
method: 'POST',
|
|
body: { type: 'Tournaments', attributes: data },
|
|
communityId,
|
|
scopeType
|
|
});
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
/**
|
|
* Update tournament
|
|
* @param {string} id - Tournament ID
|
|
* @param {Object} data - Updated fields
|
|
* @param {Object} options - Options
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
update: async (id, data, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(`tournaments/${id}.json`, {
|
|
method: 'PUT',
|
|
body: { type: 'Tournaments', attributes: data },
|
|
communityId,
|
|
scopeType
|
|
});
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
/**
|
|
* Delete tournament
|
|
* @param {string} id - Tournament ID
|
|
* @param {Object} options - Options
|
|
* @returns {Promise<null>}
|
|
*/
|
|
delete: async (id, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
return await makeRequest(`tournaments/${id}.json`, {
|
|
method: 'DELETE',
|
|
communityId,
|
|
scopeType
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Change tournament state
|
|
* @param {string} id - Tournament ID
|
|
* @param {string} state - New state
|
|
* @param {Object} options - Options
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
changeState: async (id, state, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${id}/change_state.json`,
|
|
{
|
|
method: 'PUT',
|
|
body: { type: 'TournamentState', attributes: { state } },
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
// Convenience methods
|
|
start: (id, options) => tournaments.changeState(id, 'start', options),
|
|
finalize: (id, options) => tournaments.changeState(id, 'finalize', options),
|
|
reset: (id, options) => tournaments.changeState(id, 'reset', options),
|
|
processCheckIn: (id, options) =>
|
|
tournaments.changeState(id, 'process_checkin', options)
|
|
};
|
|
|
|
// ==================== Participant Methods ====================
|
|
const participants = {
|
|
list: async (tournamentId, options = {}) => {
|
|
const { communityId, scopeType, page, per_page, ifNoneMatch } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants.json`,
|
|
{
|
|
params: { page, per_page },
|
|
communityId,
|
|
scopeType,
|
|
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
get: async (tournamentId, participantId, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants/${participantId}.json`,
|
|
{ communityId, scopeType }
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
create: async (tournamentId, data, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants.json`,
|
|
{
|
|
method: 'POST',
|
|
body: { type: 'Participants', attributes: data },
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
update: async (tournamentId, participantId, data, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants/${participantId}.json`,
|
|
{
|
|
method: 'PUT',
|
|
body: { type: 'Participants', attributes: data },
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
delete: async (tournamentId, participantId, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
return await makeRequest(
|
|
`tournaments/${tournamentId}/participants/${participantId}.json`,
|
|
{ method: 'DELETE', communityId, scopeType }
|
|
);
|
|
},
|
|
|
|
bulkAdd: async (tournamentId, participantsData, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants/bulk_add.json`,
|
|
{
|
|
method: 'POST',
|
|
body: {
|
|
type: 'Participants',
|
|
attributes: { participants: participantsData }
|
|
},
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
clear: async (tournamentId, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
return await makeRequest(
|
|
`tournaments/${tournamentId}/participants/clear.json`,
|
|
{
|
|
method: 'DELETE',
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
},
|
|
|
|
randomize: async (tournamentId, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/participants/randomize.json`,
|
|
{ method: 'PUT', communityId, scopeType }
|
|
);
|
|
return unwrapResponse(response);
|
|
}
|
|
};
|
|
|
|
// ==================== Match Methods ====================
|
|
const matches = {
|
|
list: async (tournamentId, options = {}) => {
|
|
const {
|
|
communityId,
|
|
scopeType,
|
|
state,
|
|
participant_id,
|
|
page,
|
|
per_page,
|
|
ifNoneMatch
|
|
} = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/matches.json`,
|
|
{
|
|
params: { state, participant_id, page, per_page },
|
|
communityId,
|
|
scopeType,
|
|
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
get: async (tournamentId, matchId, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/matches/${matchId}.json`,
|
|
{ communityId, scopeType }
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
update: async (tournamentId, matchId, data, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/matches/${matchId}.json`,
|
|
{
|
|
method: 'PUT',
|
|
body: { type: 'Match', attributes: data },
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
changeState: async (tournamentId, matchId, state, options = {}) => {
|
|
const { communityId, scopeType } = options;
|
|
const response = await makeRequest(
|
|
`tournaments/${tournamentId}/matches/${matchId}/change_state.json`,
|
|
{
|
|
method: 'PUT',
|
|
body: { type: 'MatchState', attributes: { state } },
|
|
communityId,
|
|
scopeType
|
|
}
|
|
);
|
|
return unwrapResponse(response);
|
|
},
|
|
|
|
reopen: (tournamentId, matchId, options) =>
|
|
matches.changeState(tournamentId, matchId, 'reopen', options),
|
|
markAsUnderway: (tournamentId, matchId, options) =>
|
|
matches.changeState(tournamentId, matchId, 'mark_as_underway', options)
|
|
};
|
|
|
|
// ==================== User & Community Methods ====================
|
|
const user = {
|
|
getMe: async () => {
|
|
const response = await makeRequest('me.json');
|
|
return unwrapResponse(response);
|
|
}
|
|
};
|
|
|
|
const communities = {
|
|
list: async () => {
|
|
const response = await makeRequest('communities.json');
|
|
return unwrapResponse(response);
|
|
}
|
|
};
|
|
|
|
return {
|
|
tournaments,
|
|
participants,
|
|
matches,
|
|
user,
|
|
communities,
|
|
// Expose request count for debugging
|
|
getRequestCount: () => requestCount
|
|
};
|
|
}
|