Compare commits
2 Commits
prod-worki
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8775f8b1fe | |||
| 700c1cbbbe |
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -1,5 +1,9 @@
|
||||
# GitHub Copilot Instructions for Memory Palace
|
||||
|
||||
## MUST FOLLOW
|
||||
Do not run commands in the same terminal where the server was started.
|
||||
Always open a new terminal for running new commands or tests if the active terminal has a long-running process.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hybrid workspace combining Obsidian-style knowledge management with code development. Uses Obsidian MD for VSCode extension (wiki-links, backlinks, graph view) alongside JavaScript/Python development tools.
|
||||
|
||||
@@ -65,43 +65,6 @@ server {
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
|
||||
# Proxy Challonge API requests to avoid CORS
|
||||
location /api/challonge/ {
|
||||
# Remove /api/challonge prefix and forward to Challonge API
|
||||
rewrite ^/api/challonge/(.*) /$1 break;
|
||||
|
||||
proxy_pass https://api.challonge.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host api.challonge.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS headers for browser requests
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Handle preflight OPTIONS requests
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Timeout settings
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
128
code/websites/pokedex.online/package-lock.json
generated
128
code/websites/pokedex.online/package-lock.json
generated
@@ -1774,6 +1774,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -2097,6 +2103,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -2352,6 +2377,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
@@ -3565,6 +3599,55 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
@@ -3595,6 +3678,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/logform": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||
@@ -4561,7 +4686,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -5892,10 +6016,12 @@
|
||||
"name": "pokedex-online-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
74
code/websites/pokedex.online/server/middleware/csrf.js
Normal file
74
code/websites/pokedex.online/server/middleware/csrf.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { COOKIE_NAMES } from '../utils/cookie-options.js';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function getCookieValuesFromHeader(cookieHeader, name) {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') return [];
|
||||
const values = [];
|
||||
const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g');
|
||||
let match;
|
||||
while ((match = pattern.exec(cookieHeader)) !== null) {
|
||||
values.push(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function csrfMiddleware(options = {}) {
|
||||
const {
|
||||
cookieName = COOKIE_NAMES.csrf,
|
||||
headerName = 'x-csrf-token',
|
||||
requireOriginCheck = false,
|
||||
allowedOrigin = null
|
||||
} = options;
|
||||
|
||||
return function csrf(req, res, next) {
|
||||
if (SAFE_METHODS.has(req.method)) return next();
|
||||
|
||||
// Optional origin check hardening (recommended in production)
|
||||
if (requireOriginCheck && allowedOrigin) {
|
||||
const origin = req.headers.origin;
|
||||
const referer = req.headers.referer;
|
||||
const ok =
|
||||
(origin && origin === allowedOrigin) ||
|
||||
(!origin && referer && referer.startsWith(allowedOrigin));
|
||||
|
||||
if (!ok) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF origin check failed',
|
||||
code: 'CSRF_ORIGIN_FAILED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const csrfCookie = req.cookies?.[cookieName];
|
||||
const csrfHeader = req.headers[headerName];
|
||||
|
||||
// Handle duplicate cookies with the same name (e.g. legacy '/api' path plus
|
||||
// current '/' path). cookie-parser will pick one value, but the browser may
|
||||
// send both. Accept if the header matches ANY provided cookie value.
|
||||
const rawHeader = req.headers?.cookie || '';
|
||||
const rawValues = getCookieValuesFromHeader(rawHeader, cookieName).map(
|
||||
v => {
|
||||
try {
|
||||
return decodeURIComponent(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
);
|
||||
const anyMatch = csrfHeader && rawValues.includes(csrfHeader);
|
||||
|
||||
if (
|
||||
!csrfHeader ||
|
||||
(!csrfCookie && !anyMatch) ||
|
||||
(csrfCookie !== csrfHeader && !anyMatch)
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF validation failed',
|
||||
code: 'CSRF_FAILED'
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
96
code/websites/pokedex.online/server/middleware/sid.js
Normal file
96
code/websites/pokedex.online/server/middleware/sid.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
generateToken,
|
||||
getLegacyCsrfCookieOptions,
|
||||
getLegacySidCookieOptions,
|
||||
getSidCookieOptions
|
||||
} from '../utils/cookie-options.js';
|
||||
|
||||
function signSid(sessionSecret, sid) {
|
||||
return crypto
|
||||
.createHmac('sha256', sessionSecret)
|
||||
.update(sid)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function parseAndVerifySignedSid(sessionSecret, signedValue) {
|
||||
if (!signedValue || typeof signedValue !== 'string') return null;
|
||||
const idx = signedValue.lastIndexOf('.');
|
||||
if (idx <= 0) return null;
|
||||
|
||||
const sid = signedValue.slice(0, idx);
|
||||
const sig = signedValue.slice(idx + 1);
|
||||
if (!sid || !sig) return null;
|
||||
|
||||
const expected = signSid(sessionSecret, sid);
|
||||
try {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sid;
|
||||
}
|
||||
|
||||
function getCookieValuesFromHeader(cookieHeader, name) {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') return [];
|
||||
|
||||
// Multiple cookies with the same name can exist if older cookies were scoped
|
||||
// to a different path (e.g. '/api') than newer ones ('/').
|
||||
const values = [];
|
||||
const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g');
|
||||
let match;
|
||||
while ((match = pattern.exec(cookieHeader)) !== null) {
|
||||
values.push(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function sidMiddleware({ sessionSecret, config }) {
|
||||
if (!sessionSecret) {
|
||||
throw new Error('sidMiddleware requires sessionSecret');
|
||||
}
|
||||
|
||||
return function sid(req, res, next) {
|
||||
// If older cookies (scoped to '/api') exist alongside newer cookies
|
||||
// (scoped to '/'), browsers may send both. Some parsers will then pick the
|
||||
// "wrong" one depending on header order, causing auth to appear connected
|
||||
// in one request and missing in another.
|
||||
const rawCookieHeader = req.headers?.cookie || '';
|
||||
if (rawCookieHeader.includes(`${COOKIE_NAMES.sid}=`)) {
|
||||
res.clearCookie(COOKIE_NAMES.sid, getLegacySidCookieOptions(config));
|
||||
}
|
||||
if (rawCookieHeader.includes(`${COOKIE_NAMES.csrf}=`)) {
|
||||
res.clearCookie(COOKIE_NAMES.csrf, getLegacyCsrfCookieOptions(config));
|
||||
}
|
||||
|
||||
const signedCandidates = getCookieValuesFromHeader(
|
||||
rawCookieHeader,
|
||||
COOKIE_NAMES.sid
|
||||
);
|
||||
const signedFromParser = req.cookies?.[COOKIE_NAMES.sid];
|
||||
if (signedFromParser) signedCandidates.push(signedFromParser);
|
||||
|
||||
// If multiple signed SIDs are present (legacy '/api' cookie + current '/'),
|
||||
// browsers tend to send the more-specific path cookie first.
|
||||
// Prefer the last valid SID to bias towards the newer '/' cookie.
|
||||
let sid = null;
|
||||
for (let i = signedCandidates.length - 1; i >= 0; i -= 1) {
|
||||
const signed = signedCandidates[i];
|
||||
sid = parseAndVerifySignedSid(sessionSecret, signed);
|
||||
if (sid) break;
|
||||
}
|
||||
|
||||
if (!sid) {
|
||||
sid = generateToken(24);
|
||||
const signedSid = `${sid}.${signSid(sessionSecret, sid)}`;
|
||||
res.cookie(COOKIE_NAMES.sid, signedSid, getSidCookieOptions(config));
|
||||
}
|
||||
|
||||
req.sid = sid;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -12,34 +12,22 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import gamemasterRouter from './gamemaster-api.js';
|
||||
import { createAuthRouter } from './routes/auth.js';
|
||||
import { createOAuthRouter } from './routes/oauth.js';
|
||||
import { createSessionRouter } from './routes/session.js';
|
||||
import { createChallongeProxyRouter } from './routes/challonge.js';
|
||||
import { createDiscordRouter } from './routes/discord.js';
|
||||
import { validateOrExit, getConfig } from './utils/env-validator.js';
|
||||
import logger, { requestLogger, errorLogger } from './utils/logger.js';
|
||||
import {
|
||||
setupGracefulShutdown,
|
||||
createHealthCheckMiddleware
|
||||
} from './utils/graceful-shutdown.js';
|
||||
|
||||
async function safeParseJsonResponse(response) {
|
||||
const rawText = await response.text();
|
||||
if (!rawText) {
|
||||
return { data: {}, rawText: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
return { data: JSON.parse(rawText), rawText };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: {
|
||||
error: 'Invalid JSON response from upstream',
|
||||
raw: rawText.slice(0, 1000)
|
||||
},
|
||||
rawText
|
||||
};
|
||||
}
|
||||
}
|
||||
import { sidMiddleware } from './middleware/sid.js';
|
||||
import { csrfMiddleware } from './middleware/csrf.js';
|
||||
import { createOAuthTokenStore } from './services/oauth-token-store.js';
|
||||
|
||||
// Validate environment variables
|
||||
validateOrExit();
|
||||
@@ -49,11 +37,33 @@ const config = getConfig();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Behind nginx reverse proxy in production
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Middleware
|
||||
app.use(cors({ origin: config.cors.origin }));
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use(requestLogger);
|
||||
|
||||
// Per-session identity (httpOnly signed SID cookie)
|
||||
app.use(
|
||||
sidMiddleware({
|
||||
sessionSecret: config.session.secret,
|
||||
config
|
||||
})
|
||||
);
|
||||
|
||||
// Encrypted per-session provider token store
|
||||
const tokenStore = createOAuthTokenStore({
|
||||
sessionSecret: config.session.secret
|
||||
});
|
||||
|
||||
// Mount API routes (nginx strips /api/ prefix before forwarding)
|
||||
app.use('/gamemaster', gamemasterRouter);
|
||||
app.use(
|
||||
@@ -64,235 +74,22 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* POST /oauth/token
|
||||
* Supports multiple providers: Challonge, Discord
|
||||
*/
|
||||
app.post('/oauth/token', async (req, res) => {
|
||||
const { code, provider = 'challonge' } = req.body;
|
||||
// Session + CSRF helpers
|
||||
app.use('/session', createSessionRouter({ config, tokenStore }));
|
||||
|
||||
if (!code) {
|
||||
logger.warn('OAuth token request missing authorization code');
|
||||
return res.status(400).json({ error: 'Missing authorization code' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle Discord OAuth
|
||||
if (provider === 'discord') {
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
process.env.VITE_DISCORD_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
logger.warn('Discord OAuth not configured', {
|
||||
hasClientId: !!clientId,
|
||||
hasClientSecret: !!clientSecret,
|
||||
hasRedirectUri: !!redirectUri
|
||||
});
|
||||
return res.status(503).json({
|
||||
error: 'Discord OAuth not configured',
|
||||
message:
|
||||
'Set VITE_DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_REDIRECT_URI environment variables'
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('Exchanging Discord authorization code for access token');
|
||||
const response = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri
|
||||
// Provider OAuth (server-owned tokens; browser never receives access/refresh tokens)
|
||||
app.use(
|
||||
'/oauth',
|
||||
csrfMiddleware({
|
||||
requireOriginCheck: config.isProduction,
|
||||
allowedOrigin: config.cors.origin
|
||||
})
|
||||
});
|
||||
);
|
||||
app.use('/oauth', createOAuthRouter({ config, tokenStore }));
|
||||
|
||||
const { data, rawText } = await safeParseJsonResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Discord token exchange failed', {
|
||||
status: response.status,
|
||||
data
|
||||
});
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
logger.error('Discord token exchange returned invalid payload', {
|
||||
status: response.status,
|
||||
raw: rawText.slice(0, 1000)
|
||||
});
|
||||
return res.status(502).json({
|
||||
error: 'Invalid response from Discord',
|
||||
raw: rawText.slice(0, 1000)
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch Discord user info to check permissions
|
||||
try {
|
||||
const userResponse = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json();
|
||||
const username = userData.username?.toLowerCase();
|
||||
const globalName = userData.global_name?.toLowerCase();
|
||||
const discordId = userData.id;
|
||||
|
||||
logger.info('Discord user authenticated', {
|
||||
username: userData.username,
|
||||
id: discordId
|
||||
});
|
||||
|
||||
// Check if user is in admin list
|
||||
const isAdmin = config.discord.adminUsers.some(
|
||||
adminUser =>
|
||||
adminUser === username ||
|
||||
adminUser === globalName ||
|
||||
adminUser === discordId
|
||||
);
|
||||
|
||||
// Add user info and permissions to response
|
||||
data.discord_user = {
|
||||
id: discordId,
|
||||
username: userData.username,
|
||||
global_name: userData.global_name,
|
||||
discriminator: userData.discriminator,
|
||||
avatar: userData.avatar
|
||||
};
|
||||
|
||||
data.permissions = isAdmin ? ['developer_tools.view'] : [];
|
||||
|
||||
if (isAdmin) {
|
||||
logger.info('Discord user granted developer access', {
|
||||
username: userData.username
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn('Failed to fetch Discord user info', {
|
||||
status: userResponse.status
|
||||
});
|
||||
}
|
||||
} catch (userError) {
|
||||
logger.warn('Error fetching Discord user info', {
|
||||
error: userError.message
|
||||
});
|
||||
// Continue without user info - token is still valid
|
||||
}
|
||||
|
||||
logger.info('Discord token exchange successful');
|
||||
return res.json(data);
|
||||
}
|
||||
|
||||
// Handle Challonge OAuth (default)
|
||||
if (!config.challonge.configured) {
|
||||
logger.warn('OAuth token request received but Challonge not configured');
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
message:
|
||||
'Set CHALLONGE_CLIENT_ID and CHALLONGE_CLIENT_SECRET environment variables'
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('Exchanging Challonge authorization code for access token');
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: config.challonge.redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Challonge token exchange failed', {
|
||||
status: response.status,
|
||||
data
|
||||
});
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
logger.info('Challonge token exchange successful');
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Token exchange error', { provider, error: error.message });
|
||||
res.status(500).json({
|
||||
error: 'Token exchange failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
* POST /oauth/refresh
|
||||
*/
|
||||
app.post('/oauth/refresh', async (req, res) => {
|
||||
if (!config.challonge.configured) {
|
||||
logger.warn('OAuth refresh request received but Challonge not configured');
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
message:
|
||||
'Set CHALLONGE_CLIENT_ID and CHALLONGE_CLIENT_SECRET environment variables'
|
||||
});
|
||||
}
|
||||
|
||||
const { refresh_token } = req.body;
|
||||
|
||||
if (!refresh_token) {
|
||||
logger.warn('OAuth refresh request missing refresh token');
|
||||
return res.status(400).json({ error: 'Missing refresh token' });
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('Refreshing access token');
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
refresh_token: refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Token refresh failed', { status: response.status, data });
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
logger.info('Token refresh successful');
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error', { error: error.message });
|
||||
res.status(500).json({
|
||||
error: 'Token refresh failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
// Provider API proxies (no split brain)
|
||||
app.use('/challonge', createChallongeProxyRouter({ config, tokenStore }));
|
||||
app.use('/discord', createDiscordRouter({ tokenStore }));
|
||||
|
||||
/**
|
||||
* Health check endpoint (with graceful shutdown support)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"main": "oauth-proxy.js",
|
||||
"scripts": {
|
||||
"start": "node oauth-proxy.js",
|
||||
"dev": "node oauth-proxy.js",
|
||||
"dev": "DOTENV_CONFIG_PATH=.env.development node oauth-proxy.js",
|
||||
"build": "echo 'Backend is Node.js - no build step required'",
|
||||
"gamemaster": "node gamemaster-api.js",
|
||||
"gamemaster": "DOTENV_CONFIG_PATH=.env.development node gamemaster-api.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "echo 'Add ESLint when ready'",
|
||||
@@ -16,8 +16,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"winston": "^3.11.0"
|
||||
|
||||
270
code/websites/pokedex.online/server/routes/challonge.js
Normal file
270
code/websites/pokedex.online/server/routes/challonge.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
function isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt - 30_000; // 30s buffer
|
||||
}
|
||||
|
||||
async function refreshUserOAuth({ config, refreshToken }) {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const err = new Error('Challonge refresh failed');
|
||||
err.status = response.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function exchangeClientCredentials({ clientId, clientSecret, scope }) {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
...(scope ? { scope } : {})
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const err = new Error('Challonge client_credentials exchange failed');
|
||||
err.status = response.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function computeExpiresAt(expiresInSeconds) {
|
||||
const ttl = Number(expiresInSeconds || 0);
|
||||
if (!ttl || Number.isNaN(ttl)) return null;
|
||||
return Date.now() + ttl * 1000;
|
||||
}
|
||||
|
||||
export function createChallongeProxyRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Proxy all Challonge requests through backend; auth is derived from SID-stored credentials.
|
||||
router.all('/*', async (req, res) => {
|
||||
try {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challongeRecord =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
// Determine upstream path relative to this router mount
|
||||
// This router is mounted at /challonge, so req.url starts with /v1/... or /v2.1/...
|
||||
const upstreamPath = req.url.replace(/^\/+/, '');
|
||||
const upstreamUrl = new URL(`https://api.challonge.com/${upstreamPath}`);
|
||||
|
||||
const authTypeRaw = req.header('Authorization-Type');
|
||||
const authType = authTypeRaw?.toLowerCase();
|
||||
const wantsApplication = upstreamPath.startsWith('v2.1/application/');
|
||||
|
||||
const isSafeMethod = req.method === 'GET' || req.method === 'HEAD';
|
||||
|
||||
// Build headers
|
||||
const headers = { ...req.headers };
|
||||
delete headers.host;
|
||||
delete headers.connection;
|
||||
delete headers['content-length'];
|
||||
|
||||
// Normalize sensitive/auth headers (avoid duplicate casing like
|
||||
// 'authorization-type' + 'Authorization-Type' which can confuse upstream)
|
||||
delete headers.authorization;
|
||||
delete headers.Authorization;
|
||||
delete headers['authorization-type'];
|
||||
delete headers['Authorization-Type'];
|
||||
|
||||
// Apply auth based on request + stored credentials
|
||||
if (upstreamPath.startsWith('v1/')) {
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge API key not configured for this session',
|
||||
code: 'CHALLONGE_API_KEY_REQUIRED'
|
||||
});
|
||||
}
|
||||
upstreamUrl.searchParams.set('api_key', apiKey);
|
||||
} else if (upstreamPath.startsWith('v2.1/')) {
|
||||
if (wantsApplication) {
|
||||
const app = challongeRecord.client_credentials;
|
||||
if (!app?.client_id || !app?.client_secret) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
'Challonge client credentials not configured for this session',
|
||||
code: 'CHALLONGE_CLIENT_CREDENTIALS_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken = app.access_token;
|
||||
if (!accessToken || isExpired(app.expires_at)) {
|
||||
const exchanged = await exchangeClientCredentials({
|
||||
clientId: app.client_id,
|
||||
clientSecret: app.client_secret,
|
||||
scope: app.scope
|
||||
});
|
||||
challongeRecord.client_credentials = {
|
||||
...app,
|
||||
access_token: exchanged.access_token,
|
||||
token_type: exchanged.token_type,
|
||||
scope: exchanged.scope,
|
||||
expires_at: computeExpiresAt(exchanged.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(
|
||||
req.sid,
|
||||
'challonge',
|
||||
challongeRecord
|
||||
);
|
||||
accessToken = challongeRecord.client_credentials.access_token;
|
||||
}
|
||||
|
||||
headers['authorization'] = `Bearer ${accessToken}`;
|
||||
headers['authorization-type'] = 'v2';
|
||||
} else if (authType === 'v1') {
|
||||
// v2.1 supports legacy API key via Authorization header + Authorization-Type: v1
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge API key not configured for this session',
|
||||
code: 'CHALLONGE_API_KEY_REQUIRED'
|
||||
});
|
||||
}
|
||||
headers['authorization'] = apiKey;
|
||||
headers['authorization-type'] = 'v1';
|
||||
} else {
|
||||
// default to user OAuth (Bearer)
|
||||
const user = challongeRecord.user_oauth;
|
||||
if (!user?.access_token) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge OAuth not connected for this session',
|
||||
code: 'CHALLONGE_OAUTH_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken = user.access_token;
|
||||
if (
|
||||
isExpired(user.expires_at) &&
|
||||
user.refresh_token &&
|
||||
config.challonge.configured
|
||||
) {
|
||||
try {
|
||||
const refreshed = await refreshUserOAuth({
|
||||
config,
|
||||
refreshToken: user.refresh_token
|
||||
});
|
||||
challongeRecord.user_oauth = {
|
||||
...user,
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token || user.refresh_token,
|
||||
token_type: refreshed.token_type,
|
||||
scope: refreshed.scope,
|
||||
expires_at: computeExpiresAt(refreshed.expires_in)
|
||||
};
|
||||
await tokenStore.setProviderRecord(
|
||||
req.sid,
|
||||
'challonge',
|
||||
challongeRecord
|
||||
);
|
||||
accessToken = challongeRecord.user_oauth.access_token;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to refresh Challonge user OAuth token', {
|
||||
status: err.status,
|
||||
payload: err.payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
headers['authorization'] = `Bearer ${accessToken}`;
|
||||
headers['authorization-type'] = 'v2';
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOptions = {
|
||||
method: req.method,
|
||||
headers
|
||||
};
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
if (req.body !== undefined && req.body !== null) {
|
||||
// express.json parsed it already
|
||||
fetchOptions.body = JSON.stringify(req.body);
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResponse = await fetch(upstreamUrl.toString(), fetchOptions);
|
||||
|
||||
// If user OAuth is present but invalid/revoked, the upstream may return 401/403.
|
||||
// For safe methods, fall back to the stored API key if available.
|
||||
// This helps avoid a confusing "connected" state that still can't query tournaments.
|
||||
if (
|
||||
isSafeMethod &&
|
||||
upstreamPath.startsWith('v2.1/') &&
|
||||
!wantsApplication &&
|
||||
authType !== 'v1' &&
|
||||
(upstreamResponse.status === 401 || upstreamResponse.status === 403)
|
||||
) {
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (apiKey) {
|
||||
logger.warn(
|
||||
'Challonge v2.1 user OAuth unauthorized; retrying with API key',
|
||||
{
|
||||
status: upstreamResponse.status,
|
||||
path: upstreamPath
|
||||
}
|
||||
);
|
||||
|
||||
const retryHeaders = { ...headers };
|
||||
delete retryHeaders.authorization;
|
||||
delete retryHeaders.Authorization;
|
||||
delete retryHeaders['authorization-type'];
|
||||
delete retryHeaders['Authorization-Type'];
|
||||
retryHeaders['authorization'] = apiKey;
|
||||
retryHeaders['authorization-type'] = 'v1';
|
||||
|
||||
upstreamResponse = await fetch(upstreamUrl.toString(), {
|
||||
...fetchOptions,
|
||||
headers: retryHeaders
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Forward status + headers (minimal)
|
||||
res.status(upstreamResponse.status);
|
||||
const contentType = upstreamResponse.headers.get('content-type');
|
||||
if (contentType) res.setHeader('content-type', contentType);
|
||||
|
||||
const buf = await upstreamResponse.arrayBuffer();
|
||||
return res.send(Buffer.from(buf));
|
||||
} catch (err) {
|
||||
logger.error('Challonge proxy error', { error: err.message });
|
||||
return res.status(502).json({
|
||||
error: 'Challonge proxy failed',
|
||||
code: 'CHALLONGE_PROXY_FAILED'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
41
code/websites/pokedex.online/server/routes/discord.js
Normal file
41
code/websites/pokedex.online/server/routes/discord.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export function createDiscordRouter({ tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/profile', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, 'discord');
|
||||
const accessToken = record?.access_token;
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({
|
||||
error: 'Not connected to Discord',
|
||||
code: 'DISCORD_NOT_CONNECTED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
return res.status(response.status).json({
|
||||
error: 'Failed to fetch Discord profile',
|
||||
code: 'DISCORD_PROFILE_FAILED',
|
||||
details: details.slice(0, 1000)
|
||||
});
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
return res.json({ user });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
519
code/websites/pokedex.online/server/routes/oauth.js
Normal file
519
code/websites/pokedex.online/server/routes/oauth.js
Normal file
@@ -0,0 +1,519 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
function computeExpiresAt(expiresInSeconds) {
|
||||
const ttl = Number(expiresInSeconds || 0);
|
||||
if (!ttl || Number.isNaN(ttl)) return null;
|
||||
return Date.now() + ttl * 1000;
|
||||
}
|
||||
|
||||
function redactProviderRecord(provider, record) {
|
||||
if (!record) {
|
||||
if (provider === 'challonge') {
|
||||
return {
|
||||
provider,
|
||||
connected: false,
|
||||
methods: {
|
||||
user_oauth: {
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
},
|
||||
client_credentials: {
|
||||
stored: false,
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
},
|
||||
api_key: {
|
||||
stored: false,
|
||||
connected: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
return {
|
||||
provider,
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
};
|
||||
}
|
||||
|
||||
return { provider, connected: false };
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
return {
|
||||
provider,
|
||||
connected: !!record.access_token,
|
||||
expires_at: record.expires_at || null,
|
||||
scope: record.scope || null
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
const user = record.user_oauth;
|
||||
const app = record.client_credentials;
|
||||
const apiKey = record.api_key;
|
||||
|
||||
return {
|
||||
provider,
|
||||
connected: !!(user?.access_token || app?.access_token || apiKey?.token),
|
||||
methods: {
|
||||
user_oauth: {
|
||||
connected: !!user?.access_token,
|
||||
expires_at: user?.expires_at || null,
|
||||
scope: user?.scope || null
|
||||
},
|
||||
client_credentials: {
|
||||
stored: !!(app?.client_id && app?.client_secret),
|
||||
connected: !!app?.access_token,
|
||||
expires_at: app?.expires_at || null,
|
||||
scope: app?.scope || null
|
||||
},
|
||||
api_key: {
|
||||
stored: !!apiKey?.token,
|
||||
connected: !!apiKey?.token
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { provider, connected: true };
|
||||
}
|
||||
|
||||
export function createOAuthRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:provider/status', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, provider);
|
||||
return res.json(redactProviderRecord(provider, record));
|
||||
});
|
||||
|
||||
router.post('/:provider/disconnect', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
await tokenStore.deleteProviderRecord(req.sid, provider);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Exchange authorization code (server stores tokens; frontend never receives them)
|
||||
router.post('/:provider/exchange', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
const { code } = req.body || {};
|
||||
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Authorization code is required',
|
||||
code: 'MISSING_CODE'
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
process.env.VITE_DISCORD_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
return res.status(503).json({
|
||||
error: 'Discord OAuth not configured',
|
||||
code: 'DISCORD_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { raw: text.slice(0, 1000) };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Discord token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Discord token exchange failed',
|
||||
code: 'DISCORD_TOKEN_EXCHANGE_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const record = {
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'discord', record);
|
||||
return res.json(redactProviderRecord('discord', record));
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
if (!config.challonge.configured || !config.challonge.redirectUri) {
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
code: 'CHALLONGE_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
code,
|
||||
redirect_uri: config.challonge.redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
logger.warn('Challonge token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge token exchange failed',
|
||||
code: 'CHALLONGE_TOKEN_EXCHANGE_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const user_oauth = {
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
user_oauth
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
error: `Unknown provider: ${provider}`,
|
||||
code: 'UNKNOWN_PROVIDER'
|
||||
});
|
||||
});
|
||||
|
||||
// Store Challonge API key (v1 compatibility) per session
|
||||
router.post('/challonge/api-key', async (req, res) => {
|
||||
let { apiKey } = req.body || {};
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
if (!apiKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'apiKey is required', code: 'MISSING_API_KEY' });
|
||||
}
|
||||
|
||||
apiKey = String(apiKey).trim();
|
||||
if (apiKey.toLowerCase().startsWith('bearer ')) {
|
||||
apiKey = apiKey.slice('bearer '.length).trim();
|
||||
}
|
||||
if (!apiKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'apiKey is required', code: 'MISSING_API_KEY' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = {
|
||||
...existing,
|
||||
api_key: {
|
||||
token: apiKey
|
||||
}
|
||||
};
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
router.post('/challonge/api-key/clear', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = { ...existing };
|
||||
if (record.api_key) delete record.api_key;
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Store Challonge client credentials and exchange token per session
|
||||
router.post('/challonge/client-credentials', async (req, res) => {
|
||||
let { clientId, clientSecret, scope } = req.body || {};
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
if (typeof clientId === 'string') clientId = clientId.trim();
|
||||
if (typeof clientSecret === 'string') clientSecret = clientSecret.trim();
|
||||
if (typeof scope === 'string') scope = scope.trim();
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const prev = existing.client_credentials || {};
|
||||
const effectiveClientId = clientId || prev.client_id;
|
||||
const effectiveClientSecret = clientSecret || prev.client_secret;
|
||||
const effectiveScope = scope || prev.scope;
|
||||
|
||||
if (!effectiveClientId || !effectiveClientSecret) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'clientId and clientSecret are required (or must already be stored for this session)',
|
||||
code: 'MISSING_CLIENT_CREDENTIALS'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: effectiveClientId,
|
||||
client_secret: effectiveClientSecret,
|
||||
...(effectiveScope ? { scope: effectiveScope } : {})
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
logger.warn('Challonge client_credentials token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge client credentials exchange failed',
|
||||
code: 'CHALLONGE_CLIENT_CREDENTIALS_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
client_credentials: {
|
||||
client_id: effectiveClientId,
|
||||
client_secret: effectiveClientSecret,
|
||||
access_token: payload.access_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
router.post('/challonge/client-credentials/clear', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = { ...existing };
|
||||
if (record.client_credentials) delete record.client_credentials;
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Logout client credentials token but retain stored client_id/client_secret
|
||||
router.post('/challonge/client-credentials/logout', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const creds = existing.client_credentials;
|
||||
if (!creds) {
|
||||
return res.json(redactProviderRecord('challonge', existing));
|
||||
}
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
client_credentials: {
|
||||
client_id: creds.client_id,
|
||||
client_secret: creds.client_secret
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Refresh stored OAuth tokens (no tokens returned to browser)
|
||||
router.post('/:provider/refresh', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, provider);
|
||||
if (!record) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'No stored tokens', code: 'NO_TOKENS' });
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
const refreshToken = record.refresh_token;
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({
|
||||
error: 'No refresh token available',
|
||||
code: 'NO_REFRESH_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
return res.status(503).json({
|
||||
error: 'Discord OAuth not configured',
|
||||
code: 'DISCORD_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Discord refresh failed',
|
||||
code: 'DISCORD_REFRESH_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...record,
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token || record.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'discord', updated);
|
||||
return res.json(redactProviderRecord('discord', updated));
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
const user = record.user_oauth;
|
||||
if (!user?.refresh_token) {
|
||||
return res.status(400).json({
|
||||
error: 'No refresh token available',
|
||||
code: 'NO_REFRESH_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.challonge.configured) {
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
code: 'CHALLONGE_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
refresh_token: user.refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge refresh failed',
|
||||
code: 'CHALLONGE_REFRESH_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRecord = {
|
||||
...record,
|
||||
user_oauth: {
|
||||
...user,
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token || user.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', updatedRecord);
|
||||
return res.json(redactProviderRecord('challonge', updatedRecord));
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
error: `Unknown provider: ${provider}`,
|
||||
code: 'UNKNOWN_PROVIDER'
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
167
code/websites/pokedex.online/server/routes/session.js
Normal file
167
code/websites/pokedex.online/server/routes/session.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
getCsrfCookieOptions,
|
||||
generateToken
|
||||
} from '../utils/cookie-options.js';
|
||||
|
||||
export function createSessionRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
async function probeChallonge(url, headers) {
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'GET', headers });
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
let bodyText = '';
|
||||
try {
|
||||
bodyText = await resp.text();
|
||||
} catch {
|
||||
bodyText = '';
|
||||
}
|
||||
|
||||
// Keep response small & safe
|
||||
const snippet = (bodyText || '').slice(0, 500);
|
||||
let json = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
json = bodyText ? JSON.parse(bodyText) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: resp.ok,
|
||||
status: resp.status,
|
||||
contentType,
|
||||
snippet,
|
||||
json
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
contentType: null,
|
||||
snippet: err?.message || 'probe failed',
|
||||
json: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure SID exists (sid middleware should run before this)
|
||||
router.get('/init', async (req, res) => {
|
||||
try {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
await tokenStore.touchSession(req.sid);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
error: err.message || 'Failed to init session',
|
||||
code: 'SESSION_INIT_FAILED'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Issue/refresh CSRF token cookie
|
||||
router.get('/csrf', (req, res) => {
|
||||
const token = generateToken(24);
|
||||
res.cookie(COOKIE_NAMES.csrf, token, getCsrfCookieOptions(config));
|
||||
res.json({ csrfToken: token });
|
||||
});
|
||||
|
||||
// Dev helper: confirm which SID the browser is using and whether provider
|
||||
// credentials are present for that SID. Does not return secrets.
|
||||
router.get('/whoami', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challonge =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
return res.json({
|
||||
sid: req.sid,
|
||||
challonge: {
|
||||
hasApiKey: !!challonge.api_key?.token,
|
||||
hasUserOAuth: !!challonge.user_oauth?.access_token,
|
||||
userOAuthExpiresAt: challonge.user_oauth?.expires_at || null,
|
||||
hasClientCredentials: !!(
|
||||
challonge.client_credentials?.client_id &&
|
||||
challonge.client_credentials?.client_secret
|
||||
),
|
||||
hasClientCredentialsToken: !!challonge.client_credentials?.access_token,
|
||||
clientCredentialsExpiresAt:
|
||||
challonge.client_credentials?.expires_at || null
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dev-only: verify challonge upstream auth for this SID (no secrets returned)
|
||||
router.get('/challonge/verify', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challonge =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
const base =
|
||||
'https://api.challonge.com/v2.1/tournaments.json?page=1&per_page=1&state=pending';
|
||||
const results = {
|
||||
sid: req.sid,
|
||||
endpoints: {
|
||||
userTournamentsSample: base,
|
||||
appTournamentsSample:
|
||||
'https://api.challonge.com/v2.1/application/tournaments.json?page=1&per_page=1&state=pending'
|
||||
},
|
||||
methods: {
|
||||
user_oauth: {
|
||||
present: !!challonge.user_oauth?.access_token,
|
||||
probe: null
|
||||
},
|
||||
api_key: { present: !!challonge.api_key?.token, probe: null },
|
||||
client_credentials: {
|
||||
present: !!challonge.client_credentials?.access_token,
|
||||
probe: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (challonge.user_oauth?.access_token) {
|
||||
results.methods.user_oauth.probe = await probeChallonge(base, {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: `Bearer ${challonge.user_oauth.access_token}`,
|
||||
'authorization-type': 'v2'
|
||||
});
|
||||
}
|
||||
|
||||
if (challonge.api_key?.token) {
|
||||
results.methods.api_key.probe = await probeChallonge(base, {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: challonge.api_key.token,
|
||||
'authorization-type': 'v1'
|
||||
});
|
||||
}
|
||||
|
||||
if (challonge.client_credentials?.access_token) {
|
||||
results.methods.client_credentials.probe = await probeChallonge(
|
||||
results.endpoints.appTournamentsSample,
|
||||
{
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: `Bearer ${challonge.client_credentials.access_token}`,
|
||||
'authorization-type': 'v2'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return res.json(results);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const STORE_PATH = path.join(__dirname, '..', 'data', 'oauth-tokens.json');
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function getEncryptionKey(sessionSecret) {
|
||||
const raw = process.env.OAUTH_TOKEN_ENC_KEY;
|
||||
if (raw) {
|
||||
// Expect base64 32 bytes. If it's longer, hash it down.
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length === 32) return buf;
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
// Dev fallback: derive from session secret (still better than plaintext)
|
||||
logger.warn(
|
||||
'OAUTH_TOKEN_ENC_KEY not set; deriving key from SESSION_SECRET (dev only).'
|
||||
);
|
||||
return crypto.createHash('sha256').update(sessionSecret).digest();
|
||||
}
|
||||
|
||||
function encryptJson(key, plaintextObj) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const plaintext = Buffer.from(JSON.stringify(plaintextObj), 'utf8');
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
alg: 'aes-256-gcm',
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function decryptJson(key, envelope) {
|
||||
if (!envelope || envelope.version !== STORE_VERSION) {
|
||||
return { sessions: {}, version: STORE_VERSION };
|
||||
}
|
||||
if (envelope.alg !== 'aes-256-gcm') {
|
||||
throw new Error(`Unsupported store encryption alg: ${envelope.alg}`);
|
||||
}
|
||||
|
||||
const iv = Buffer.from(envelope.iv, 'base64');
|
||||
const tag = Buffer.from(envelope.tag, 'base64');
|
||||
const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final()
|
||||
]);
|
||||
return JSON.parse(plaintext.toString('utf8'));
|
||||
}
|
||||
|
||||
async function readStoreFile() {
|
||||
try {
|
||||
const raw = await fs.readFile(STORE_PATH, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStoreFile(envelope) {
|
||||
await fs.mkdir(path.dirname(STORE_PATH), { recursive: true });
|
||||
const tmp = `${STORE_PATH}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), 'utf8');
|
||||
await fs.rename(tmp, STORE_PATH);
|
||||
} finally {
|
||||
// Best-effort cleanup if something failed before rename.
|
||||
try {
|
||||
await fs.unlink(tmp);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthTokenStore({ sessionSecret }) {
|
||||
if (!sessionSecret) {
|
||||
throw new Error('createOAuthTokenStore requires sessionSecret');
|
||||
}
|
||||
|
||||
const key = getEncryptionKey(sessionSecret);
|
||||
|
||||
let cache = null;
|
||||
let cacheLoadedAt = 0;
|
||||
|
||||
// Serialize writes to avoid races under concurrent requests.
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
if (cache) return cache;
|
||||
|
||||
const envelope = await readStoreFile();
|
||||
if (!envelope) {
|
||||
cache = { version: STORE_VERSION, sessions: {} };
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
}
|
||||
|
||||
try {
|
||||
cache = decryptJson(key, envelope);
|
||||
if (!cache.sessions) cache.sessions = {};
|
||||
cache.version = STORE_VERSION;
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
} catch (err) {
|
||||
logger.error('Failed to decrypt oauth token store; starting fresh', {
|
||||
error: err.message
|
||||
});
|
||||
cache = { version: STORE_VERSION, sessions: {} };
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(state) {
|
||||
const envelope = encryptJson(key, state);
|
||||
const run = async () => {
|
||||
await writeStoreFile(envelope);
|
||||
};
|
||||
|
||||
// Keep the chain alive even if a prior write failed.
|
||||
writeChain = writeChain.then(run, run);
|
||||
await writeChain;
|
||||
}
|
||||
|
||||
function ensureSession(state, sid) {
|
||||
const existing = state.sessions[sid];
|
||||
const ts = now();
|
||||
if (existing) {
|
||||
existing.lastSeenAt = ts;
|
||||
existing.expiresAt = Math.min(
|
||||
existing.createdAt + SEVEN_DAYS_MS,
|
||||
ts + ONE_DAY_MS
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const createdAt = ts;
|
||||
const session = {
|
||||
createdAt,
|
||||
lastSeenAt: ts,
|
||||
expiresAt: Math.min(createdAt + SEVEN_DAYS_MS, ts + ONE_DAY_MS),
|
||||
providers: {}
|
||||
};
|
||||
|
||||
state.sessions[sid] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function sweep(state) {
|
||||
const ts = now();
|
||||
let removed = 0;
|
||||
for (const [sid, session] of Object.entries(state.sessions)) {
|
||||
if (!session?.expiresAt || session.expiresAt <= ts) {
|
||||
delete state.sessions[sid];
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
logger.info('Swept expired OAuth sessions', { removed });
|
||||
}
|
||||
}
|
||||
|
||||
async function touchSession(sid) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
ensureSession(state, sid);
|
||||
await persist(state);
|
||||
}
|
||||
|
||||
async function getProviderRecord(sid, provider) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
await persist(state);
|
||||
return session.providers?.[provider] || null;
|
||||
}
|
||||
|
||||
async function setProviderRecord(sid, provider, record) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
session.providers[provider] = {
|
||||
...record,
|
||||
updatedAt: now()
|
||||
};
|
||||
await persist(state);
|
||||
}
|
||||
|
||||
async function deleteProviderRecord(sid, provider) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
if (session.providers?.[provider]) {
|
||||
delete session.providers[provider];
|
||||
await persist(state);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
touchSession,
|
||||
getProviderRecord,
|
||||
setProviderRecord,
|
||||
deleteProviderRecord
|
||||
};
|
||||
}
|
||||
79
code/websites/pokedex.online/server/utils/cookie-options.js
Normal file
79
code/websites/pokedex.online/server/utils/cookie-options.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ONE_DAY_SECONDS = 60 * 60 * 24;
|
||||
const SEVEN_DAYS_SECONDS = ONE_DAY_SECONDS * 7;
|
||||
|
||||
export const COOKIE_NAMES = {
|
||||
sid: 'pdx_sid',
|
||||
csrf: 'pdx_csrf'
|
||||
};
|
||||
|
||||
export function getCookieSecurityConfig(config) {
|
||||
const deploymentTarget =
|
||||
config?.deploymentTarget || process.env.DEPLOYMENT_TARGET;
|
||||
const nodeEnv = config?.nodeEnv || process.env.NODE_ENV;
|
||||
|
||||
const isProdTarget =
|
||||
deploymentTarget === 'production' || nodeEnv === 'production';
|
||||
|
||||
return {
|
||||
secure: isProdTarget,
|
||||
sameSite: 'lax'
|
||||
};
|
||||
}
|
||||
|
||||
export function getSidCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: SEVEN_DAYS_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy cookie options used before widening cookie scope to '/'.
|
||||
// Clearing these prevents browsers from sending multiple cookies with the same
|
||||
// name but different paths (e.g. '/api' and '/'), which can cause session
|
||||
// split-brain.
|
||||
export function getLegacySidCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/api',
|
||||
maxAge: SEVEN_DAYS_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function getCsrfCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: ONE_DAY_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function getLegacyCsrfCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/api',
|
||||
maxAge: ONE_DAY_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function generateToken(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
@@ -73,6 +73,36 @@ const REQUIRED_ENV_VARS = {
|
||||
: null
|
||||
},
|
||||
|
||||
// Token encryption key (required for server-side OAuth token storage in production)
|
||||
OAUTH_TOKEN_ENC_KEY: {
|
||||
required: false,
|
||||
description:
|
||||
'Base64-encoded 32-byte key for encrypting OAuth tokens at rest (AES-256-GCM)',
|
||||
validate: (val, env) => {
|
||||
const target = env?.DEPLOYMENT_TARGET;
|
||||
if (target !== 'production') return true;
|
||||
if (!val) {
|
||||
console.error(
|
||||
'❌ OAUTH_TOKEN_ENC_KEY is required in production to encrypt OAuth tokens'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Best-effort validation: base64 decode should yield 32 bytes
|
||||
try {
|
||||
const buf = Buffer.from(val, 'base64');
|
||||
return buf.length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Admin auth
|
||||
ADMIN_PASSWORD: {
|
||||
required: false,
|
||||
description: 'Admin password for /auth/login (recommended for production)'
|
||||
},
|
||||
|
||||
// Challonge OAuth (optional)
|
||||
CHALLONGE_CLIENT_ID: {
|
||||
required: false,
|
||||
@@ -217,6 +247,10 @@ export function getConfig() {
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production'
|
||||
},
|
||||
|
||||
// Admin auth (JWT secret uses session secret for now)
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
adminPassword: process.env.ADMIN_PASSWORD,
|
||||
|
||||
// Discord User Permissions
|
||||
discord: {
|
||||
adminUsers: process.env.DISCORD_ADMIN_USERS
|
||||
|
||||
@@ -139,7 +139,9 @@ const isOpen = ref(false);
|
||||
// 2. JWT authenticated users with developer_tools.view permission
|
||||
// 3. Discord authenticated users with developer_tools.view permission
|
||||
const isAvailable = computed(() => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
// Vite-native dev detection (reliable in the browser).
|
||||
// In production builds, this is always false.
|
||||
const isDev = import.meta.env.DEV === true;
|
||||
|
||||
// Check JWT auth permissions
|
||||
const hasJwtPermission = user.value?.permissions?.includes(
|
||||
@@ -154,7 +156,7 @@ const isAvailable = computed(() => {
|
||||
return isDev || hasPermission;
|
||||
});
|
||||
|
||||
const nodeEnv = computed(() => process.env.NODE_ENV || 'unknown');
|
||||
const nodeEnv = computed(() => import.meta.env.MODE || 'unknown');
|
||||
const appVersion = computed(
|
||||
() => import.meta.env.VITE_APP_VERSION || '1.0.0-dev'
|
||||
);
|
||||
|
||||
@@ -1,58 +1,57 @@
|
||||
/**
|
||||
* useChallongeApiKey Composable
|
||||
* Manages Challonge API key storage in browser localStorage
|
||||
* Works on mobile, desktop, and tablets
|
||||
* Manages Challonge API key storage on the backend per-session (SID cookie)
|
||||
* No-split-brain: API key never lives in browser storage.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
const STORAGE_KEY = 'challonge_api_key';
|
||||
const storedKey = ref(getStoredKey());
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Get API key from localStorage
|
||||
* @returns {string|null} Stored API key or null
|
||||
*/
|
||||
function getStoredKey() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || null;
|
||||
} catch (error) {
|
||||
console.warn('localStorage not available:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key to localStorage
|
||||
* Save API key server-side (per-session)
|
||||
* @param {string} apiKey - The API key to store
|
||||
* @returns {boolean} Success status
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
function saveApiKey(apiKey) {
|
||||
try {
|
||||
async function saveApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
throw new Error('Invalid API key format');
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, apiKey);
|
||||
storedKey.value = apiKey;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
error.value = 'Invalid API key format';
|
||||
return false;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiClient.post('/oauth/challonge/api-key', { apiKey });
|
||||
status.value = data;
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear API key from localStorage
|
||||
* @returns {boolean} Success status
|
||||
* Clear API key server-side (per-session)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
function clearApiKey() {
|
||||
async function clearApiKey() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
storedKey.value = null;
|
||||
const data = await apiClient.post('/oauth/challonge/api-key/clear', {});
|
||||
status.value = data;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear API key:', error);
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,34 +61,54 @@ function clearApiKey() {
|
||||
* @returns {string|null} Masked key or null
|
||||
*/
|
||||
const maskedKey = computed(() => {
|
||||
if (!storedKey.value) return null;
|
||||
const key = storedKey.value;
|
||||
if (key.length < 8) return '••••••••';
|
||||
return `${key.slice(0, 4)}•••••••${key.slice(-4)}`;
|
||||
if (!isKeyStored.value) return null;
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
// Backwards-compat for older views: truthy string when stored
|
||||
const storedKey = computed(() => maskedKey.value);
|
||||
|
||||
/**
|
||||
* Check if API key is stored
|
||||
* @returns {boolean} True if key exists
|
||||
*/
|
||||
const isKeyStored = computed(() => !!storedKey.value);
|
||||
const isKeyStored = computed(() => {
|
||||
return !!status.value?.methods?.api_key?.connected;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the full API key (use with caution)
|
||||
* @returns {string|null} Full API key or null
|
||||
*/
|
||||
function getApiKey() {
|
||||
return storedKey.value;
|
||||
// No-split-brain: never expose raw key to browser
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
status.value = await apiClient.get('/oauth/challonge/status');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useChallongeApiKey() {
|
||||
// Fire-and-forget initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
saveApiKey,
|
||||
clearApiKey,
|
||||
getApiKey,
|
||||
getStoredKey,
|
||||
storedKey: computed(() => storedKey.value),
|
||||
refreshStatus,
|
||||
status: computed(() => status.value),
|
||||
loading: computed(() => loading.value),
|
||||
error: computed(() => error.value),
|
||||
maskedKey,
|
||||
storedKey,
|
||||
isKeyStored
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ export function useChallongeClient(options = {}) {
|
||||
const { debug = false } = options;
|
||||
|
||||
// Get authentication sources
|
||||
const { getApiKey } = useChallongeApiKey();
|
||||
const { isAuthenticated: isOAuthAuthenticated, accessToken: oauthToken } =
|
||||
useChallongeOAuth();
|
||||
const { isKeyStored } = useChallongeApiKey();
|
||||
const { isAuthenticated: isOAuthAuthenticated } = useChallongeOAuth();
|
||||
const {
|
||||
isAuthenticated: isClientCredsAuthenticated,
|
||||
accessToken: clientCredsToken
|
||||
@@ -50,13 +49,13 @@ export function useChallongeClient(options = {}) {
|
||||
const tournamentScope = ref(ScopeType.USER);
|
||||
const debugMode = ref(debug);
|
||||
|
||||
// Reactive API key
|
||||
const apiKey = computed(() => getApiKey());
|
||||
// No-split-brain: raw keys/tokens are never available in the browser
|
||||
const apiKey = computed(() => null);
|
||||
|
||||
// Masked API key for display
|
||||
const maskedApiKey = computed(() => {
|
||||
if (!apiKey.value) return '';
|
||||
return apiKey.value.slice(0, 4) + '•••••••' + apiKey.value.slice(-4);
|
||||
if (!isKeyStored.value) return '';
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -65,8 +64,8 @@ export function useChallongeClient(options = {}) {
|
||||
const client = computed(() => {
|
||||
if (apiVersion.value === 'v1') {
|
||||
// v1 only supports API key
|
||||
if (!apiKey.value) return null;
|
||||
return createChallongeV1Client(apiKey.value);
|
||||
if (!isKeyStored.value) return null;
|
||||
return createChallongeV1Client(null);
|
||||
} else {
|
||||
// v2.1 supports OAuth, client credentials, and API key
|
||||
// Smart priority based on scope selection:
|
||||
@@ -75,7 +74,7 @@ export function useChallongeClient(options = {}) {
|
||||
|
||||
if (tournamentScope.value === ScopeType.APPLICATION) {
|
||||
// APPLICATION scope - prefer client credentials
|
||||
if (isClientCredsAuthenticated.value && clientCredsToken.value) {
|
||||
if (isClientCredsAuthenticated.value) {
|
||||
if (debugMode.value) {
|
||||
console.log(
|
||||
'🔐 Using Client Credentials token for APPLICATION scope'
|
||||
@@ -85,43 +84,38 @@ export function useChallongeClient(options = {}) {
|
||||
{ token: clientCredsToken.value, type: AuthType.OAUTH },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
} else if (isOAuthAuthenticated.value && oauthToken.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔐 Using OAuth user token for APPLICATION scope');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: oauthToken.value, type: AuthType.OAUTH },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
|
||||
// Backend requires client_credentials for /v2.1/application/*
|
||||
return null;
|
||||
} else {
|
||||
// USER scope - prefer OAuth user tokens or API key
|
||||
if (isOAuthAuthenticated.value && oauthToken.value) {
|
||||
if (isOAuthAuthenticated.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔐 Using OAuth user token for USER scope');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: oauthToken.value, type: AuthType.OAUTH },
|
||||
{ token: null, type: AuthType.OAUTH },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
} else if (apiKey.value) {
|
||||
} else if (isKeyStored.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔑 Using API Key for USER scope');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: apiKey.value, type: AuthType.API_KEY },
|
||||
{ token: null, type: AuthType.API_KEY },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try API key
|
||||
if (apiKey.value) {
|
||||
if (isKeyStored.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔑 Using API Key (fallback)');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: apiKey.value, type: AuthType.API_KEY },
|
||||
{ token: null, type: AuthType.API_KEY },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
@@ -137,13 +131,14 @@ export function useChallongeClient(options = {}) {
|
||||
if (apiVersion.value === 'v1') {
|
||||
return 'API Key';
|
||||
}
|
||||
if (isClientCredsAuthenticated.value) {
|
||||
return 'Client Credentials';
|
||||
|
||||
if (tournamentScope.value === ScopeType.APPLICATION) {
|
||||
return isClientCredsAuthenticated.value ? 'Client Credentials' : 'None';
|
||||
}
|
||||
if (isOAuthAuthenticated.value) {
|
||||
return 'OAuth';
|
||||
}
|
||||
return 'API Key';
|
||||
|
||||
if (isOAuthAuthenticated.value) return 'OAuth';
|
||||
if (isKeyStored.value) return 'API Key';
|
||||
return 'None';
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,278 +1,148 @@
|
||||
/**
|
||||
* Challonge Client Credentials Flow Composable
|
||||
* Challonge Client Credentials Composable (SERVER-SIDE)
|
||||
*
|
||||
* Manages client credentials OAuth flow for server-to-server authentication
|
||||
* Used for APPLICATION scope access (application:manage)
|
||||
*
|
||||
* Features:
|
||||
* - Client credentials token exchange
|
||||
* - Automatic token refresh
|
||||
* - Secure credential storage
|
||||
* - Token expiration handling
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* import { useChallongeClientCredentials } from '@/composables/useChallongeClientCredentials'
|
||||
*
|
||||
* const {
|
||||
* isAuthenticated,
|
||||
* accessToken,
|
||||
* authenticate,
|
||||
* logout,
|
||||
* saveCredentials
|
||||
* } = useChallongeClientCredentials()
|
||||
*
|
||||
* // Save client credentials (one time)
|
||||
* saveCredentials('your_client_id', 'your_client_secret')
|
||||
*
|
||||
* // Get access token (will auto-refresh if expired)
|
||||
* await authenticate('application:manage tournaments:read tournaments:write')
|
||||
* const token = accessToken.value
|
||||
* ```
|
||||
* No-split-brain: client credentials and tokens are stored on the backend
|
||||
* per-session (SID cookie) and never returned to the browser.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
const CREDENTIALS_KEY = 'challonge_client_credentials';
|
||||
const TOKEN_KEY = 'challonge_client_token';
|
||||
|
||||
// Shared state across all instances
|
||||
const credentials = ref(null);
|
||||
const tokenData = ref(null);
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const error = ref('');
|
||||
|
||||
// Load credentials and token from localStorage on module initialization
|
||||
try {
|
||||
const storedCreds = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (storedCreds) {
|
||||
credentials.value = JSON.parse(storedCreds);
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (storedToken) {
|
||||
tokenData.value = JSON.parse(storedToken);
|
||||
|
||||
// Check if token is expired
|
||||
if (
|
||||
tokenData.value.expires_at &&
|
||||
Date.now() >= tokenData.value.expires_at
|
||||
) {
|
||||
console.log('🔄 Client credentials token expired, will need to refresh');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load client credentials:', err);
|
||||
function secondsUntil(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
}
|
||||
|
||||
export function useChallongeClientCredentials() {
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!tokenData.value?.access_token && !isExpired.value;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!tokenData.value?.expires_at) return true;
|
||||
return Date.now() >= tokenData.value.expires_at;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
if (isExpired.value) return null;
|
||||
return tokenData.value?.access_token || null;
|
||||
});
|
||||
const method = computed(() => status.value?.methods?.client_credentials);
|
||||
|
||||
const hasCredentials = computed(() => {
|
||||
return !!(credentials.value?.client_id && credentials.value?.client_secret);
|
||||
return !!method.value?.stored;
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!method.value?.connected;
|
||||
});
|
||||
|
||||
const maskedClientId = computed(() => {
|
||||
if (!credentials.value?.client_id) return null;
|
||||
const id = credentials.value.client_id;
|
||||
if (id.length < 12) return id.slice(0, 4) + '••••';
|
||||
return id.slice(0, 6) + '•••••••' + id.slice(-4);
|
||||
if (!hasCredentials.value) return '';
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
/**
|
||||
* Save client credentials to localStorage
|
||||
* @param {string} clientId - OAuth client ID
|
||||
* @param {string} clientSecret - OAuth client secret
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function saveCredentials(clientId, clientSecret) {
|
||||
try {
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Client ID and secret are required');
|
||||
}
|
||||
|
||||
credentials.value = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
saved_at: new Date().toISOString()
|
||||
const tokenInfo = computed(() => {
|
||||
const expiresAt = method.value?.expires_at;
|
||||
return {
|
||||
expiresAt: expiresAt || null,
|
||||
expiresIn: secondsUntil(expiresAt)
|
||||
};
|
||||
});
|
||||
|
||||
localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials.value));
|
||||
console.log('✅ Client credentials saved');
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Failed to save credentials:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials and token
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function clearCredentials() {
|
||||
try {
|
||||
credentials.value = null;
|
||||
tokenData.value = null;
|
||||
localStorage.removeItem(CREDENTIALS_KEY);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
console.log('✅ Client credentials cleared');
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Failed to clear credentials:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using client credentials flow
|
||||
* @param {string} scope - Requested scope (e.g., 'application:manage')
|
||||
* @returns {Promise<string>} Access token
|
||||
*/
|
||||
async function authenticate(scope = 'application:manage') {
|
||||
if (!hasCredentials.value) {
|
||||
throw new Error(
|
||||
'Client credentials not configured. Use saveCredentials() first.'
|
||||
);
|
||||
}
|
||||
|
||||
// Return existing token if still valid
|
||||
if (isAuthenticated.value && !isExpired.value) {
|
||||
console.log('✅ Using existing valid token');
|
||||
return accessToken.value;
|
||||
async function refreshStatus() {
|
||||
status.value = await apiClient.get('/oauth/challonge/status');
|
||||
return status.value;
|
||||
}
|
||||
|
||||
async function saveCredentials(clientId, clientSecret, scope) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
error.value = '';
|
||||
try {
|
||||
console.log('🔐 Requesting client credentials token...');
|
||||
console.log(' Client ID:', maskedClientId.value);
|
||||
console.log(' Scope:', scope);
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: credentials.value.client_id,
|
||||
client_secret: credentials.value.client_secret,
|
||||
scope: scope
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
`Token request failed: ${response.status}`
|
||||
);
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials',
|
||||
{
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store token with expiration
|
||||
tokenData.value = {
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type,
|
||||
scope: data.scope,
|
||||
created_at: Date.now(),
|
||||
expires_in: data.expires_in,
|
||||
expires_at: Date.now() + data.expires_in * 1000
|
||||
};
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenData.value));
|
||||
|
||||
console.log('✅ Client credentials token obtained');
|
||||
console.log(' Expires in:', data.expires_in, 'seconds');
|
||||
console.log(' Scope:', data.scope);
|
||||
|
||||
return tokenData.value.access_token;
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('❌ Client credentials authentication failed:', err);
|
||||
throw err;
|
||||
error.value = err.message || 'Failed to save credentials';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force token refresh
|
||||
* @param {string} scope - Requested scope
|
||||
* @returns {Promise<string>} New access token
|
||||
*/
|
||||
async function refresh(scope = 'application:manage') {
|
||||
// Clear existing token
|
||||
tokenData.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
async function authenticate(scope = 'application:manage') {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials',
|
||||
{ scope }
|
||||
);
|
||||
return true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get new token
|
||||
async function refresh(scope = 'application:manage') {
|
||||
return authenticate(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear token (keeps credentials)
|
||||
*/
|
||||
function logout() {
|
||||
tokenData.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
console.log('✅ Logged out (credentials retained)');
|
||||
async function logout() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials/logout',
|
||||
{}
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Logout failed';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token info for debugging
|
||||
*/
|
||||
const tokenInfo = computed(() => {
|
||||
if (!tokenData.value) return null;
|
||||
async function clearCredentials() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials/clear',
|
||||
{}
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to clear credentials';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiresAt = tokenData.value.expires_at;
|
||||
const timeUntilExpiry = expiresAt ? expiresAt - now : 0;
|
||||
// Best-effort initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus().catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
hasToken: !!tokenData.value.access_token,
|
||||
isExpired: isExpired.value,
|
||||
scope: tokenData.value.scope,
|
||||
expiresIn: Math.floor(timeUntilExpiry / 1000),
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toLocaleString() : null
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
isExpired,
|
||||
accessToken,
|
||||
hasCredentials,
|
||||
maskedClientId,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
loading: computed(() => loading.value),
|
||||
error: computed({
|
||||
get: () => error.value,
|
||||
set: v => {
|
||||
error.value = v || '';
|
||||
}
|
||||
}),
|
||||
tokenInfo,
|
||||
|
||||
// Actions
|
||||
saveCredentials,
|
||||
clearCredentials,
|
||||
authenticate,
|
||||
refresh,
|
||||
logout
|
||||
logout,
|
||||
refreshStatus,
|
||||
status: computed(() => status.value)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,45 +11,71 @@
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCsrfCookie() {
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf) return;
|
||||
try {
|
||||
await apiClient.get('/session/csrf', { deduplicate: false });
|
||||
} catch {
|
||||
// Let the POST surface the failure.
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'challonge_oauth_tokens';
|
||||
const CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID;
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_CHALLONGE_REDIRECT_URI ||
|
||||
`${window.location.origin}/oauth/callback`;
|
||||
|
||||
// Shared state across all instances
|
||||
const tokens = ref(null);
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Load tokens from localStorage on module initialization
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
tokens.value = JSON.parse(stored);
|
||||
|
||||
// Check if token is expired
|
||||
if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) {
|
||||
console.log('🔄 Token expired, will need to refresh');
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const data = await apiClient.get('/oauth/challonge/status', {
|
||||
deduplicate: false
|
||||
});
|
||||
if (data) status.value = data;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load OAuth tokens:', err);
|
||||
}
|
||||
|
||||
export function useChallongeOAuth() {
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!tokens.value?.access_token;
|
||||
return !!status.value?.methods?.user_oauth?.connected;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!tokens.value?.expires_at) return false;
|
||||
return Date.now() >= tokens.value.expires_at;
|
||||
const expiresAt = status.value?.methods?.user_oauth?.expires_at;
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt;
|
||||
});
|
||||
|
||||
const expiresIn = computed(() => {
|
||||
const expiresAt = status.value?.methods?.user_oauth?.expires_at;
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
return tokens.value?.access_token || null;
|
||||
// No-split-brain: token never available in browser
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -59,13 +85,18 @@ export function useChallongeOAuth() {
|
||||
* @returns {Object} Object with authUrl and state
|
||||
*/
|
||||
function getAuthorizationUrl(
|
||||
scope = 'tournaments:read tournaments:write',
|
||||
scopeOrOptions = 'tournaments:read tournaments:write',
|
||||
state = null
|
||||
) {
|
||||
if (!CLIENT_ID) {
|
||||
throw new Error('VITE_CHALLONGE_CLIENT_ID not configured');
|
||||
}
|
||||
|
||||
const scope =
|
||||
typeof scopeOrOptions === 'string'
|
||||
? scopeOrOptions
|
||||
: scopeOrOptions?.scope || 'tournaments:read tournaments:write';
|
||||
|
||||
// Generate state if not provided
|
||||
const oauthState = state || generateState();
|
||||
|
||||
@@ -94,6 +125,10 @@ export function useChallongeOAuth() {
|
||||
|
||||
// Store state for CSRF protection
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
sessionStorage.setItem('oauth_provider', 'challonge');
|
||||
if (typeof scope === 'object' && scope?.return_to) {
|
||||
sessionStorage.setItem('oauth_return_to', scope.return_to);
|
||||
}
|
||||
|
||||
console.log('🔐 Starting OAuth flow with state:', state);
|
||||
|
||||
@@ -130,44 +165,14 @@ export function useChallongeOAuth() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token exchange failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
await ensureCsrfCookie();
|
||||
const data = await apiClient.post('/oauth/challonge/exchange', { code });
|
||||
status.value = data;
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
|
||||
console.log('✅ OAuth authentication successful');
|
||||
return tokens.value;
|
||||
return status.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token exchange error:', err);
|
||||
@@ -181,53 +186,14 @@ export function useChallongeOAuth() {
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshToken() {
|
||||
if (!tokens.value?.refresh_token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: tokens.value.refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token refresh failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || tokens.value.refresh_token, // Keep old if not provided
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
refreshed_at: Date.now()
|
||||
};
|
||||
|
||||
// Store updated tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
status.value = await apiClient.post('/oauth/challonge/refresh', {});
|
||||
|
||||
console.log('✅ Token refreshed successfully');
|
||||
return tokens.value;
|
||||
return status.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token refresh error:', err);
|
||||
@@ -244,30 +210,20 @@ export function useChallongeOAuth() {
|
||||
* Get valid access token (refreshes if expired)
|
||||
*/
|
||||
async function getValidToken() {
|
||||
if (!tokens.value) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// If token is expired or about to expire (within 5 minutes), refresh it
|
||||
const expiresIn = tokens.value.expires_at - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (expiresIn < fiveMinutes) {
|
||||
console.log('🔄 Token expired or expiring soon, refreshing...');
|
||||
await refreshToken();
|
||||
}
|
||||
|
||||
return tokens.value.access_token;
|
||||
throw new Error(
|
||||
'No-split-brain: Challonge OAuth token is not accessible in the browser'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear tokens
|
||||
*/
|
||||
function logout() {
|
||||
tokens.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
status.value = null;
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
console.log('👋 Logged out');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
apiClient.post('/oauth/challonge/disconnect', {}).catch(() => {});
|
||||
console.log('👋 Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,9 +239,10 @@ export function useChallongeOAuth() {
|
||||
|
||||
return {
|
||||
// State
|
||||
tokens: computed(() => tokens.value),
|
||||
tokens: computed(() => status.value),
|
||||
isAuthenticated,
|
||||
isExpired,
|
||||
expiresIn,
|
||||
accessToken,
|
||||
loading: computed(() => loading.value),
|
||||
error: computed(() => error.value),
|
||||
@@ -299,3 +256,8 @@ export function useChallongeOAuth() {
|
||||
getAuthorizationUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ export function useChallongeTests(client, apiVersion, tournamentScope) {
|
||||
*/
|
||||
async function testListTournaments(resetPagination = true) {
|
||||
if (!client.value) {
|
||||
console.error('No API client available');
|
||||
tournamentListState.error.value =
|
||||
'No Challonge client available. Configure API key/OAuth/client credentials, and ensure the selected API version + scope is supported.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,13 +132,6 @@ export function useChallongeTests(client, apiVersion, tournamentScope) {
|
||||
scopeType: tournamentScope.value
|
||||
});
|
||||
|
||||
console.log('📊 Tournament API Response:', {
|
||||
page: currentPage.value,
|
||||
perPage: perPage.value,
|
||||
scope: tournamentScope.value,
|
||||
resultsCount: result.length
|
||||
});
|
||||
|
||||
totalTournaments.value = result.length;
|
||||
hasNextPage.value = result.length >= perPage.value;
|
||||
return result;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { useOAuth } from './useOAuth.js';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
// Shared Discord user profile data
|
||||
const discordUser = ref(null);
|
||||
@@ -47,24 +48,7 @@ export function useDiscordOAuth() {
|
||||
*/
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
const token = oauth.accessToken.value;
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated with Discord');
|
||||
}
|
||||
|
||||
// Fetch from backend which has the Discord token
|
||||
const response = await fetch('/api/auth/discord/profile', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'Failed to fetch Discord profile');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await apiClient.get('/discord/profile');
|
||||
discordUser.value = data.user;
|
||||
|
||||
console.log(`✅ Loaded Discord profile: ${data.user.username}`);
|
||||
@@ -103,9 +87,8 @@ export function useDiscordOAuth() {
|
||||
* @returns {boolean} True if user has developer access
|
||||
*/
|
||||
function hasDevAccess() {
|
||||
// Check if tokens include permissions
|
||||
const permissions = oauth.tokens.value?.permissions || [];
|
||||
return permissions.includes('developer_tools.view');
|
||||
// No-split-brain: permissions are not surfaced via OAuth token exchange anymore.
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,9 +21,35 @@
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { PLATFORMS } from '../config/platforms.js';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
// Multi-provider token storage (shared across all instances)
|
||||
const tokenStores = new Map();
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCsrfCookie() {
|
||||
// The backend requires double-submit CSRF for all unsafe methods.
|
||||
// The OAuth callback page can be loaded directly after provider redirect,
|
||||
// before the app's normal startup has fetched /session/csrf.
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf) return;
|
||||
|
||||
try {
|
||||
await apiClient.get('/session/csrf', { deduplicate: false });
|
||||
} catch {
|
||||
// If this fails, the subsequent POST will surface the real error.
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-provider status storage (shared across all instances)
|
||||
const statusStores = new Map();
|
||||
|
||||
/**
|
||||
* Initialize OAuth state for a provider
|
||||
@@ -33,8 +59,8 @@ const tokenStores = new Map();
|
||||
*/
|
||||
function initializeProvider(provider) {
|
||||
// Return existing state if already initialized
|
||||
if (tokenStores.has(provider)) {
|
||||
return tokenStores.get(provider);
|
||||
if (statusStores.has(provider)) {
|
||||
return statusStores.get(provider);
|
||||
}
|
||||
|
||||
// Validate platform exists
|
||||
@@ -43,35 +69,28 @@ function initializeProvider(provider) {
|
||||
throw new Error(`Platform not found: ${provider}`);
|
||||
}
|
||||
|
||||
// Get storage key from OAuth config
|
||||
const oauthConfig = platformConfig.auth.oauth;
|
||||
if (!oauthConfig?.enabled) {
|
||||
throw new Error(`OAuth not enabled for ${provider}`);
|
||||
}
|
||||
|
||||
const storageKey = oauthConfig.storageKey;
|
||||
|
||||
// Create provider-specific state
|
||||
const state = {
|
||||
tokens: ref(null),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
provider,
|
||||
storageKey
|
||||
provider
|
||||
};
|
||||
|
||||
// Load existing tokens from localStorage on initialization
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
state.tokens.value = JSON.parse(stored);
|
||||
console.log(`✅ Loaded ${provider} OAuth tokens from storage`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load ${provider} OAuth tokens:`, err);
|
||||
}
|
||||
// Best-effort initial status fetch
|
||||
apiClient
|
||||
.get(`/oauth/${provider}/status`, { deduplicate: false })
|
||||
.then(data => {
|
||||
if (data) state.tokens.value = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
tokenStores.set(provider, state);
|
||||
statusStores.set(provider, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -87,26 +106,29 @@ export function useOAuth(provider = 'challonge') {
|
||||
|
||||
// Computed properties for token state
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!state.tokens.value?.access_token;
|
||||
return !!state.tokens.value?.connected;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!state.tokens.value?.expires_at) return false;
|
||||
return Date.now() >= state.tokens.value.expires_at;
|
||||
const expiresAt = state.tokens.value?.expires_at;
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt;
|
||||
});
|
||||
|
||||
const expiresIn = computed(() => {
|
||||
if (!state.tokens.value?.expires_at) return null;
|
||||
const diff = state.tokens.value.expires_at - Date.now();
|
||||
const expiresAt = state.tokens.value?.expires_at;
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
return state.tokens.value?.access_token || null;
|
||||
// No-split-brain: tokens are never available in the browser
|
||||
return null;
|
||||
});
|
||||
|
||||
const refreshToken = computed(() => {
|
||||
return state.tokens.value?.refresh_token || null;
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -232,60 +254,35 @@ export function useOAuth(provider = 'challonge') {
|
||||
state.error.value = null;
|
||||
|
||||
try {
|
||||
// Exchange code for tokens via backend endpoint
|
||||
const response = await fetch(oauthConfig.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
provider
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
`Token exchange failed with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate token expiration time (expires_in is in seconds)
|
||||
const expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
|
||||
|
||||
// Store tokens (including permissions if provided)
|
||||
const tokens = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || null,
|
||||
token_type: data.token_type || 'Bearer',
|
||||
expires_in: data.expires_in || 3600,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
permissions: data.permissions || [], // Store permissions from backend
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
state.tokens.value = tokens;
|
||||
localStorage.setItem(state.storageKey, JSON.stringify(tokens));
|
||||
await ensureCsrfCookie();
|
||||
const data = await apiClient.post(oauthConfig.tokenEndpoint, { code });
|
||||
state.tokens.value = data;
|
||||
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
sessionStorage.removeItem('oauth_return_to');
|
||||
|
||||
console.log(
|
||||
`✅ ${provider} OAuth authentication successful, expires in ${data.expires_in}s`
|
||||
);
|
||||
return tokens;
|
||||
console.log(`✅ ${provider} OAuth authentication successful`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
state.error.value = err.message;
|
||||
const backendCode = err?.data?.code;
|
||||
const backendError = err?.data?.error;
|
||||
const details = err?.data?.details;
|
||||
|
||||
// Prefer a helpful, user-visible message over a generic HTTP status.
|
||||
let message = err?.message || 'Token exchange failed';
|
||||
if (backendError) message = backendError;
|
||||
if (backendCode) message = `${message} (${backendCode})`;
|
||||
|
||||
// If Challonge returns structured OAuth error info, surface it.
|
||||
const detailText =
|
||||
details?.error_description || details?.error || details?.message;
|
||||
if (detailText) message = `${message}: ${detailText}`;
|
||||
|
||||
state.error.value = message;
|
||||
console.error(`${provider} token exchange error:`, err);
|
||||
throw err;
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
state.loading.value = false;
|
||||
}
|
||||
@@ -299,54 +296,15 @@ export function useOAuth(provider = 'challonge') {
|
||||
* @throws {Error} If no refresh token available or refresh fails
|
||||
*/
|
||||
async function refreshTokenFn() {
|
||||
if (!state.tokens.value?.refresh_token) {
|
||||
throw new Error(`No refresh token available for ${provider}`);
|
||||
}
|
||||
|
||||
state.loading.value = true;
|
||||
state.error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(oauthConfig.refreshEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: state.tokens.value.refresh_token,
|
||||
provider
|
||||
})
|
||||
});
|
||||
const data = await apiClient.post(oauthConfig.refreshEndpoint, {});
|
||||
state.tokens.value = data;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
`Token refresh failed with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
|
||||
|
||||
// Update tokens (keep old refresh token if new one not provided)
|
||||
const tokens = {
|
||||
...state.tokens.value,
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || state.tokens.value.refresh_token,
|
||||
expires_in: data.expires_in || 3600,
|
||||
expires_at: expiresAt,
|
||||
refreshed_at: Date.now()
|
||||
};
|
||||
|
||||
state.tokens.value = tokens;
|
||||
localStorage.setItem(state.storageKey, JSON.stringify(tokens));
|
||||
|
||||
console.log(
|
||||
`✅ ${provider} token refreshed, new expiry in ${data.expires_in}s`
|
||||
);
|
||||
return tokens;
|
||||
console.log(`✅ ${provider} token refreshed`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
state.error.value = err.message;
|
||||
console.error(`${provider} token refresh error:`, err);
|
||||
@@ -367,23 +325,9 @@ export function useOAuth(provider = 'challonge') {
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function getValidToken() {
|
||||
if (!state.tokens.value) {
|
||||
throw new Error(`Not authenticated with ${provider}`);
|
||||
}
|
||||
|
||||
// Calculate time until expiry
|
||||
const expiresIn = state.tokens.value.expires_at - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
// Refresh if expired or expiring within 5 minutes
|
||||
if (expiresIn < fiveMinutes) {
|
||||
console.log(
|
||||
`🔄 ${provider} token expiring in ${Math.floor(expiresIn / 1000)}s, refreshing...`
|
||||
throw new Error(
|
||||
`No-split-brain: ${provider} OAuth token is not accessible in the browser`
|
||||
);
|
||||
await refreshTokenFn();
|
||||
}
|
||||
|
||||
return state.tokens.value.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,10 +336,12 @@ export function useOAuth(provider = 'challonge') {
|
||||
*/
|
||||
function logout() {
|
||||
state.tokens.value = null;
|
||||
localStorage.removeItem(state.storageKey);
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
sessionStorage.removeItem('oauth_return_to');
|
||||
if (oauthConfig.disconnectEndpoint) {
|
||||
apiClient.post(oauthConfig.disconnectEndpoint, {}).catch(() => {});
|
||||
}
|
||||
console.log(`👋 ${provider} logged out`);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ export const PLATFORMS = {
|
||||
label: 'OAuth 2.0',
|
||||
description: 'User token authentication for v2.1 API',
|
||||
endpoint: 'https://api.challonge.com/oauth/authorize',
|
||||
tokenEndpoint: '/api/oauth/token',
|
||||
refreshEndpoint: '/api/oauth/refresh',
|
||||
tokenEndpoint: '/oauth/challonge/exchange',
|
||||
refreshEndpoint: '/oauth/challonge/refresh',
|
||||
disconnectEndpoint: '/oauth/challonge/disconnect',
|
||||
scopes: ['tournaments:read', 'tournaments:write'],
|
||||
storageKey: 'challonge_oauth_tokens'
|
||||
},
|
||||
@@ -52,8 +53,9 @@ export const PLATFORMS = {
|
||||
label: 'OAuth 2.0',
|
||||
description: 'Verify your Discord identity',
|
||||
endpoint: 'https://discord.com/api/oauth2/authorize',
|
||||
tokenEndpoint: '/api/oauth/token',
|
||||
refreshEndpoint: '/api/oauth/refresh',
|
||||
tokenEndpoint: '/oauth/discord/exchange',
|
||||
refreshEndpoint: '/oauth/discord/refresh',
|
||||
disconnectEndpoint: '/oauth/discord/disconnect',
|
||||
scopes: ['identify'],
|
||||
storageKey: 'discord_oauth_tokens',
|
||||
userEndpoint: 'https://discord.com/api/users/@me'
|
||||
|
||||
@@ -20,4 +20,14 @@ app.use(router);
|
||||
app.use(VueVirtualScroller);
|
||||
app.directive('highlight', vHighlight);
|
||||
|
||||
app.mount('#app');
|
||||
// Prime session + CSRF cookies (server uses SID cookies and double-submit CSRF)
|
||||
(async () => {
|
||||
try {
|
||||
await fetch('/api/session/init', { credentials: 'include' });
|
||||
await fetch('/api/session/csrf', { credentials: 'include' });
|
||||
} catch (err) {
|
||||
console.warn('Failed to initialize session/CSRF cookies:', err);
|
||||
} finally {
|
||||
app.mount('#app');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -37,7 +37,8 @@ export function createChallongeV1Client(apiKey) {
|
||||
? endpoint.slice(1)
|
||||
: endpoint;
|
||||
const url = new URL(`${baseURL}${cleanEndpoint}`, window.location.origin);
|
||||
url.searchParams.append('api_key', apiKey);
|
||||
// No-split-brain: do not send api_key from the browser.
|
||||
// Backend proxy injects the per-session stored API key.
|
||||
|
||||
if (options.params) {
|
||||
Object.entries(options.params).forEach(([key, value]) => {
|
||||
|
||||
@@ -50,14 +50,10 @@ export const ScopeType = {
|
||||
* @returns {Object} API client with methods
|
||||
*/
|
||||
export function createChallongeV2Client(auth, options = {}) {
|
||||
const { token, type = AuthType.API_KEY } = auth;
|
||||
const { token, type = AuthType.API_KEY } = auth || {};
|
||||
const { communityId: defaultCommunityId, debug = false } = options;
|
||||
const baseURL = getBaseURL();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token is required');
|
||||
}
|
||||
|
||||
// Request tracking for debug mode
|
||||
let requestCount = 0;
|
||||
|
||||
@@ -109,16 +105,15 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
...headers
|
||||
};
|
||||
|
||||
// Add authorization header
|
||||
if (type === AuthType.OAUTH) {
|
||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
requestHeaders['Authorization'] = token;
|
||||
}
|
||||
// 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
|
||||
headers: requestHeaders,
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
@@ -149,13 +144,28 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
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 JSON parsing fails, create an error with the status
|
||||
if (debug)
|
||||
if (debug) {
|
||||
console.error('[Challonge v2.1 JSON Parse Error]', parseError);
|
||||
}
|
||||
const error = new Error(
|
||||
`HTTP ${response.status}: Failed to parse response`
|
||||
);
|
||||
@@ -186,14 +196,25 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
.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 error = new Error(
|
||||
`HTTP ${response.status}: ${data.message || response.statusText}`
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
|
||||
const activeRequests = new Map();
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Create an API client with configuration
|
||||
* @param {Object} config - Client configuration
|
||||
@@ -68,13 +81,31 @@ export function createApiClient(config = {}) {
|
||||
async function makeRequest(url, options) {
|
||||
const { retries = maxRetries, ...fetchOptions } = options;
|
||||
|
||||
const method = (fetchOptions.method || 'GET').toUpperCase();
|
||||
|
||||
// Merge headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...defaultHeaders,
|
||||
...fetchOptions.headers
|
||||
};
|
||||
|
||||
// Default JSON content type unless caller overrides / uses FormData
|
||||
const hasBody =
|
||||
fetchOptions.body !== undefined && fetchOptions.body !== null;
|
||||
const isFormData =
|
||||
typeof FormData !== 'undefined' && fetchOptions.body instanceof FormData;
|
||||
if (hasBody && !isFormData && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Double-submit CSRF: mirror cookie into header for state-changing requests
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf && !headers['X-CSRF-Token']) {
|
||||
headers['X-CSRF-Token'] = csrf;
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
@@ -88,6 +119,8 @@ export function createApiClient(config = {}) {
|
||||
let requestOptions = {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
credentials: fetchOptions.credentials || 'include',
|
||||
cache: fetchOptions.cache || 'no-store',
|
||||
signal: controller.signal
|
||||
};
|
||||
if (onRequest) {
|
||||
@@ -127,6 +160,14 @@ export function createApiClient(config = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Some endpoints may return 204/304 (no body). Avoid JSON parse errors.
|
||||
if (
|
||||
processedResponse.status === 204 ||
|
||||
processedResponse.status === 304
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const contentType = processedResponse.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
|
||||
@@ -39,17 +39,9 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
communityId,
|
||||
page = 1,
|
||||
per_page = 25,
|
||||
states = [
|
||||
'pending',
|
||||
'checking_in',
|
||||
'checked_in',
|
||||
'accepting_predictions',
|
||||
'group_stages_underway',
|
||||
'group_stages_finalized',
|
||||
'underway',
|
||||
'awaiting_review',
|
||||
'complete'
|
||||
],
|
||||
// Challonge v2.1 tournament list supports these canonical states.
|
||||
// (Older v1-style states like "checking_in" are not accepted.)
|
||||
states = ['pending', 'in_progress', 'ended'],
|
||||
includeCommunities = false
|
||||
} = options;
|
||||
|
||||
@@ -61,6 +53,8 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
per_page
|
||||
};
|
||||
|
||||
let firstAuthError = null;
|
||||
|
||||
// Query all states in parallel
|
||||
const promises = states.map(state =>
|
||||
client.tournaments
|
||||
@@ -69,6 +63,10 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
state
|
||||
})
|
||||
.catch(err => {
|
||||
const status = err?.status || err?.errors?.[0]?.status;
|
||||
if ((status === 401 || status === 403) && !firstAuthError) {
|
||||
firstAuthError = err;
|
||||
}
|
||||
console.error(`Error querying ${state} tournaments:`, err);
|
||||
return [];
|
||||
})
|
||||
@@ -77,6 +75,16 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
// Wait for all requests
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// If we hit an auth error and fetched nothing at all, surface the auth error
|
||||
// so the UI can prompt to connect/reconnect Challonge.
|
||||
const totalCount = results.reduce(
|
||||
(sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0),
|
||||
0
|
||||
);
|
||||
if (firstAuthError && totalCount === 0) {
|
||||
throw firstAuthError;
|
||||
}
|
||||
|
||||
// Flatten and deduplicate by tournament ID
|
||||
const tournamentMap = new Map();
|
||||
results.forEach(tournamentArray => {
|
||||
|
||||
@@ -94,15 +94,16 @@
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Secure Storage:</strong> Your API key is stored locally in
|
||||
your browser using localStorage. It never leaves your device.
|
||||
the backend for your current session (linked via an httpOnly
|
||||
cookie).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Device Specific:</strong> Each device/browser has its own
|
||||
storage. The key won't sync across devices.
|
||||
<strong>Session Scoped:</strong> Each browser session has its own
|
||||
credentials. Reconnect if you clear cookies or switch browsers.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Persistent:</strong> Your key will be available whenever you
|
||||
use this app, even after closing the browser.
|
||||
<strong>Short-lived by design:</strong> Credentials are stored for
|
||||
convenience during development, but can be cleared anytime.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Clear Anytime:</strong> Use the "Clear Stored Key" button to
|
||||
@@ -119,9 +120,8 @@
|
||||
<div class="section warning-section">
|
||||
<h2>🔒 Security Notice</h2>
|
||||
<p>
|
||||
⚠️ <strong>localStorage is not encrypted.</strong> Only use this on
|
||||
trusted devices. If you're on a shared or public computer, clear your
|
||||
API key when done.
|
||||
⚠️ Only use this on trusted devices. If you're on a shared or public
|
||||
computer, clear your API key when done.
|
||||
</p>
|
||||
<p>
|
||||
For production use, consider using a backend proxy that handles API
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="method-description">
|
||||
User token authentication for v2.1 API (APPLICATION scope)
|
||||
User OAuth for v2.1 API (USER scope)
|
||||
</p>
|
||||
|
||||
<div v-if="isChallongeOAuthAuthenticated" class="token-info">
|
||||
@@ -319,8 +319,8 @@
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>
|
||||
Your authentication tokens are stored securely in your browser's local
|
||||
storage.
|
||||
Your authentication tokens are stored server-side for your current
|
||||
session (linked via an httpOnly cookie).
|
||||
</p>
|
||||
<router-link to="/" class="btn-link">← Back Home</router-link>
|
||||
</div>
|
||||
@@ -359,10 +359,8 @@ const isChallongeOAuthAuthenticated = computed(
|
||||
);
|
||||
const challongeOAuthExpiresIn = computed(() => challongeOAuth.expiresIn.value);
|
||||
const challongeOAuthRefreshedAt = computed(() => {
|
||||
return (
|
||||
challongeOAuth.tokens.value?.refreshed_at ||
|
||||
challongeOAuth.tokens.value?.created_at
|
||||
);
|
||||
// No-split-brain: we don't surface token metadata to the browser
|
||||
return null;
|
||||
});
|
||||
|
||||
// Challonge Client Credentials
|
||||
@@ -392,9 +390,12 @@ const discordExpiresIn = computed(() => discord.expiresIn.value);
|
||||
const platforms = computed(() => getAllPlatforms());
|
||||
|
||||
// Methods
|
||||
function saveChallongeApiKey() {
|
||||
async function saveChallongeApiKey() {
|
||||
try {
|
||||
saveApiKey(newChallongeApiKey.value);
|
||||
const ok = await saveApiKey(newChallongeApiKey.value);
|
||||
if (!ok) {
|
||||
throw new Error('Failed to save API key');
|
||||
}
|
||||
newChallongeApiKey.value = '';
|
||||
successMessage.value = 'Challonge API key saved successfully!';
|
||||
setTimeout(() => (successMessage.value = ''), 3000);
|
||||
@@ -403,9 +404,9 @@ function saveChallongeApiKey() {
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChallongeApiKey() {
|
||||
async function deleteChallongeApiKey() {
|
||||
if (confirm('Are you sure? This will remove your API key.')) {
|
||||
clearApiKey();
|
||||
await clearApiKey();
|
||||
successMessage.value = 'Challonge API key deleted';
|
||||
setTimeout(() => (successMessage.value = ''), 3000);
|
||||
}
|
||||
@@ -446,12 +447,15 @@ function disconnectChallongeOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveChallongeClientCredentials() {
|
||||
async function saveChallongeClientCredentials() {
|
||||
try {
|
||||
challengeClientCreds.saveCredentials(
|
||||
const ok = await challengeClientCreds.saveCredentials(
|
||||
newClientId.value,
|
||||
newClientSecret.value
|
||||
);
|
||||
if (!ok) {
|
||||
throw new Error('Failed to save client credentials');
|
||||
}
|
||||
newClientId.value = '';
|
||||
newClientSecret.value = '';
|
||||
successMessage.value = 'Client credentials saved!';
|
||||
@@ -461,9 +465,9 @@ function saveChallongeClientCredentials() {
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChallongeClientCredentials() {
|
||||
async function deleteChallongeClientCredentials() {
|
||||
if (confirm('Delete client credentials?')) {
|
||||
challengeClientCreds.clearCredentials();
|
||||
await challengeClientCreds.clearCredentials();
|
||||
successMessage.value = 'Client credentials deleted';
|
||||
setTimeout(() => (successMessage.value = ''), 3000);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<!-- API Version & Settings Controls -->
|
||||
<div v-if="apiKey" class="section controls-section">
|
||||
<div v-if="hasAnyAuth" class="section controls-section">
|
||||
<div class="controls-grid">
|
||||
<!-- Authentication Settings Link -->
|
||||
<div class="control-group info-section">
|
||||
@@ -40,34 +40,44 @@
|
||||
</div>
|
||||
|
||||
<!-- No API Key Stored -->
|
||||
<div v-if="!apiKey" class="section warning-section no-key-section">
|
||||
<div v-if="!hasAnyAuth" class="section warning-section no-key-section">
|
||||
<div class="warning-content">
|
||||
<h2>⚠️ No API Key Found</h2>
|
||||
<h2>⚠️ No Challonge Authentication Found</h2>
|
||||
<p class="warning-text">
|
||||
Please store your Challonge API key in the API Key Manager to get
|
||||
Configure one of: API key, OAuth, or client credentials to get
|
||||
started.
|
||||
</p>
|
||||
<router-link to="/api-key-manager" class="btn btn-primary btn-lg">
|
||||
Go to API Key Manager
|
||||
<div class="button-row">
|
||||
<router-link to="/auth" class="btn btn-primary btn-lg">
|
||||
Go to Authentication Settings
|
||||
</router-link>
|
||||
<router-link to="/api-key-manager" class="btn btn-secondary btn-lg">
|
||||
API Key Manager
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="hint-text">
|
||||
The API Key Manager securely stores your key in your browser. Set it
|
||||
up once and use it across all tools.
|
||||
This app stores your Challonge credentials server-side for the
|
||||
current session (no tokens/keys in browser storage).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Tournaments Test -->
|
||||
<div v-if="apiKey" class="section">
|
||||
<div v-if="hasAnyAuth" class="section">
|
||||
<h2>Test API Connection</h2>
|
||||
<button
|
||||
@click="testListTournaments()"
|
||||
:disabled="loading"
|
||||
:disabled="loading || needsClientCredsForApplication"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ loading ? 'Loading...' : 'List My Tournaments' }}
|
||||
</button>
|
||||
|
||||
<div v-if="needsClientCredsForApplication" class="error-message">
|
||||
APPLICATION scope requires Client Credentials. Switch scope to USER or
|
||||
configure client credentials in Authentication Settings.
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="paginationInfo" class="pagination-info">
|
||||
{{ paginationInfo }}
|
||||
@@ -99,13 +109,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Configuration Instructions -->
|
||||
<div v-if="!apiKey" class="section info-section">
|
||||
<h2>ℹ️ How to Set Up Your API Key</h2>
|
||||
<div v-if="!hasAnyAuth" class="section info-section">
|
||||
<h2>ℹ️ How to Set Up Challonge Authentication</h2>
|
||||
<div class="info-steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<h3>Get Your API Key</h3>
|
||||
<h3>Get Your API Key (optional)</h3>
|
||||
<p>
|
||||
Visit
|
||||
<a
|
||||
@@ -121,10 +131,10 @@
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<h3>Store in API Key Manager</h3>
|
||||
<h3>Store via the app</h3>
|
||||
<p>
|
||||
Go to the API Key Manager and paste your key. It will be saved
|
||||
securely in your browser.
|
||||
Use Authentication Settings (OAuth / Client Credentials) or the
|
||||
API Key Manager (API key).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,11 +166,26 @@ import TournamentGrid from '../components/challonge/TournamentGrid.vue';
|
||||
import TournamentDetail from '../components/challonge/TournamentDetail.vue';
|
||||
import { ScopeType } from '../services/challonge.service.js';
|
||||
|
||||
const { getApiKey } = useChallongeApiKey();
|
||||
const apiKey = computed(() => getApiKey());
|
||||
const { isKeyStored } = useChallongeApiKey();
|
||||
const maskedApiKey = computed(() => {
|
||||
if (!apiKey.value) return '';
|
||||
return apiKey.value.slice(0, 4) + '•••••••' + apiKey.value.slice(-4);
|
||||
if (!isKeyStored.value) return '';
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
const hasAnyAuth = computed(() => {
|
||||
return (
|
||||
isKeyStored.value ||
|
||||
isAuthenticated.value ||
|
||||
isClientCredsAuthenticated.value
|
||||
);
|
||||
});
|
||||
|
||||
const needsClientCredsForApplication = computed(() => {
|
||||
return (
|
||||
apiVersion.value === 'v2.1' &&
|
||||
tournamentScope.value === ScopeType.APPLICATION &&
|
||||
!isClientCredsAuthenticated.value
|
||||
);
|
||||
});
|
||||
|
||||
// OAuth Management
|
||||
@@ -178,7 +203,7 @@ const {
|
||||
accessToken: clientCredsToken
|
||||
} = useChallongeClientCredentials();
|
||||
|
||||
// API Configuration
|
||||
// API Configuration (driven by unified client)
|
||||
const apiVersion = ref('v2.1');
|
||||
const tournamentScope = ref(ScopeType.USER);
|
||||
const perPage = ref(100);
|
||||
@@ -191,25 +216,26 @@ const apiKeyCollapsed = ref(false);
|
||||
const oauthCollapsed = ref(false);
|
||||
const clientCredsCollapsed = ref(false);
|
||||
|
||||
// Initialize Challonge Client (replaces ~100 lines of inline client creation)
|
||||
// Initialize Challonge Client (tokenless; backend derives auth from SID cookie)
|
||||
const {
|
||||
client,
|
||||
apiVersion: clientApiVersion,
|
||||
tournamentScope: clientTournamentScope,
|
||||
maskedApiKey: clientMaskedApiKey,
|
||||
authType
|
||||
} = useChallongeClient(
|
||||
apiKey,
|
||||
apiVersion,
|
||||
tournamentScope,
|
||||
{
|
||||
oauthToken: accessToken,
|
||||
oauthAuthenticated: isAuthenticated,
|
||||
clientCredsToken: clientCredsToken,
|
||||
clientCredsAuthenticated: isClientCredsAuthenticated
|
||||
},
|
||||
debugMode
|
||||
);
|
||||
} = useChallongeClient({
|
||||
debug: localStorage.getItem('DEBUG_CHALLONGE') === 'true'
|
||||
});
|
||||
|
||||
// Keep existing local controls bound to the composable state
|
||||
apiVersion.value = clientApiVersion.value;
|
||||
tournamentScope.value = clientTournamentScope.value;
|
||||
watch(apiVersion, v => {
|
||||
clientApiVersion.value = v;
|
||||
});
|
||||
watch(tournamentScope, v => {
|
||||
clientTournamentScope.value = v;
|
||||
});
|
||||
|
||||
// Initialize Tournament Tests (replaces ~200 lines of tournament logic)
|
||||
const {
|
||||
|
||||
@@ -184,8 +184,8 @@
|
||||
it to version control
|
||||
</li>
|
||||
<li>
|
||||
🔐 Credentials are stored in your browser's localStorage (not sent
|
||||
to any server)
|
||||
🔐 Credentials are stored server-side for your current session
|
||||
(linked via an httpOnly cookie)
|
||||
</li>
|
||||
<li>
|
||||
⚠️ Only use on <strong>trusted devices</strong> - Clear
|
||||
@@ -373,7 +373,7 @@ async function handleSaveCredentials() {
|
||||
|
||||
try {
|
||||
// Save credentials
|
||||
const success = saveCredentials(
|
||||
const success = await saveCredentials(
|
||||
inputClientId.value.trim(),
|
||||
inputClientSecret.value.trim()
|
||||
);
|
||||
@@ -440,7 +440,7 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
function handleDeleteCredentials() {
|
||||
const success = clearCredentials();
|
||||
clearCredentials().then(success => {
|
||||
if (success) {
|
||||
showDeleteConfirm.value = false;
|
||||
inputClientId.value = '';
|
||||
@@ -452,6 +452,7 @@ function handleDeleteCredentials() {
|
||||
} else {
|
||||
error.value = 'Failed to clear client credentials';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ScopeType } from '../../../src/services/challonge.service.js';
|
||||
// Mock dependencies before importing composable
|
||||
vi.mock('../../../src/composables/useChallongeApiKey.js', () => ({
|
||||
useChallongeApiKey: () => ({
|
||||
getApiKey: () => 'test-api-key-1234567890'
|
||||
isKeyStored: ref(true),
|
||||
getApiKey: () => null
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -91,10 +92,7 @@ describe('useChallongeClient', () => {
|
||||
it('masks API key correctly', () => {
|
||||
const { maskedApiKey } = useChallongeClient();
|
||||
|
||||
const masked = maskedApiKey.value;
|
||||
expect(masked).toContain('•••');
|
||||
expect(masked.startsWith('test')).toBe(true);
|
||||
expect(masked.endsWith('7890')).toBe(true);
|
||||
expect(maskedApiKey.value).toBe('stored on server');
|
||||
});
|
||||
|
||||
it('returns auth type', () => {
|
||||
|
||||
@@ -59,9 +59,7 @@ describe('api-client', () => {
|
||||
'/test',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
credentials: 'include'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,12 +36,28 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
strictPort: true, // Fail if port is already in use instead of trying next available port
|
||||
proxy: {
|
||||
// Session + CSRF helpers
|
||||
'/api/session': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false
|
||||
},
|
||||
|
||||
// Admin auth helpers
|
||||
'/api/auth': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false
|
||||
},
|
||||
|
||||
// API v1 proxy (legacy)
|
||||
'/api/challonge/v1': {
|
||||
target: 'https://api.challonge.com/v1',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api\/challonge\/v1/, ''),
|
||||
secure: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
@@ -49,10 +65,10 @@ export default defineConfig({
|
||||
},
|
||||
// API v2.1 proxy (current)
|
||||
'/api/challonge/v2.1': {
|
||||
target: 'https://api.challonge.com/v2.1',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api\/challonge\/v2\.1/, ''),
|
||||
secure: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json'
|
||||
@@ -69,7 +85,23 @@ export default defineConfig({
|
||||
'/api/gamemaster': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false
|
||||
},
|
||||
|
||||
// Discord API proxy
|
||||
'/api/discord': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false
|
||||
},
|
||||
|
||||
// Convenience: health check through the frontend origin
|
||||
'/api/health': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Leagues/EA Collectibles/Support Ticket - 3224942.md",
|
||||
"file": "Professor University/Untitled.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Support Ticket - 3224942"
|
||||
"title": "Untitled"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -172,6 +172,8 @@
|
||||
},
|
||||
"active": "03bc92bce96d8847",
|
||||
"lastOpenFiles": [
|
||||
"Leagues/EA Collectibles/Support Ticket - 3224942.md",
|
||||
"Professor University/Untitled.md",
|
||||
"Attachements/IMG_7127.png",
|
||||
"Attachements/IMG_7126.png",
|
||||
"Attachements/IMG_7125.png",
|
||||
@@ -191,14 +193,12 @@
|
||||
"Attachements/Gmail - June Reporting.pdf",
|
||||
"Attachements/CleanShot 2026-02-02 at 11.27.53@2x.png",
|
||||
"Leagues/Card Cycle/Things To Chat About.md",
|
||||
"Leagues/EA Collectibles/Support Ticket - 3224942.md",
|
||||
"Leagues/Card Cycle/League Approval.md",
|
||||
"Leagues/Card Cycle/League Application.md",
|
||||
"Leagues/Card Cycle/Staff.md",
|
||||
"Leagues/Getting a Store Sanctioned.md",
|
||||
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
|
||||
"Attachements/IMG_2050.jpeg",
|
||||
"Attachements/IMG_2049.jpeg",
|
||||
"Leagues/First League Challenge.md",
|
||||
"Regionals/Championship Series/2024.md",
|
||||
"Regionals/Championship Series/2027.md",
|
||||
|
||||
220
docs/projects/pokemon-professor/.sync/Archive/.obsidian/workspace.81.json
vendored
Normal file
220
docs/projects/pokemon-professor/.sync/Archive/.obsidian/workspace.81.json
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "dbea326f5a7eef0e",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "33cb3b63442ef3e2",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "03bc92bce96d8847",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Leagues/EA Collectibles/Support Ticket - 3224942.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Support Ticket - 3224942"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "51d249e97eb631df",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e27fe91559b8323a",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "6893279c01482aa5",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1354b92b77086879",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7bd7edfb815c69a6",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "1c3c022a36d199ca",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e9231b430de0b572",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "fa016c167bbc26d8",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "Leagues/Card Cycle/Things To Chat About.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks for Things To Chat About"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f7e5cdd83386832c",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "Leagues/Card Cycle/Things To Chat About.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links from Things To Chat About"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "20cd550dc7cb138c",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "751f8b968439d8d1",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "Leagues/Card Cycle/Things To Chat About.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of Things To Chat About"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"daily-notes:Open today's daily note": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false,
|
||||
"bases:Create new base": false,
|
||||
"copilot:Open Copilot Chat": false
|
||||
}
|
||||
},
|
||||
"active": "03bc92bce96d8847",
|
||||
"lastOpenFiles": [
|
||||
"Attachements/IMG_7127.png",
|
||||
"Attachements/IMG_7126.png",
|
||||
"Attachements/IMG_7125.png",
|
||||
"Attachements/IMG_7124.png",
|
||||
"Attachements/IMG_6390.png",
|
||||
"Attachements/IMG_6389.png",
|
||||
"Attachements/IMG_6388.png",
|
||||
"Attachements/Gmail - Update Email.pdf",
|
||||
"Attachements/Gmail - Transfer of League 6243556.pdf",
|
||||
"Attachements/Gmail - TPCi shipment.pdf",
|
||||
"Attachements/Gmail - Staff promos.pdf",
|
||||
"Attachements/Gmail - Some food for thought on challenges_cups.pdf",
|
||||
"Attachements/Gmail - Pre-release Tourney 11AM Sunday.pdf",
|
||||
"Attachements/Gmail - Pre release tourney player ids.pdf",
|
||||
"Attachements/Gmail - Pokémon casual play events for April.pdf",
|
||||
"Attachements/Gmail - Play! Pokémon League Challenges _ New Season Announcement.pdf",
|
||||
"Attachements/Gmail - June Reporting.pdf",
|
||||
"Attachements/CleanShot 2026-02-02 at 11.27.53@2x.png",
|
||||
"Leagues/Card Cycle/Things To Chat About.md",
|
||||
"Leagues/EA Collectibles/Support Ticket - 3224942.md",
|
||||
"Leagues/Card Cycle/League Approval.md",
|
||||
"Leagues/Card Cycle/League Application.md",
|
||||
"Leagues/Card Cycle/Staff.md",
|
||||
"Leagues/Getting a Store Sanctioned.md",
|
||||
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
|
||||
"Attachements/IMG_2050.jpeg",
|
||||
"Attachements/IMG_2049.jpeg",
|
||||
"Leagues/First League Challenge.md",
|
||||
"Regionals/Championship Series/2024.md",
|
||||
"Regionals/Championship Series/2027.md",
|
||||
"Regionals/Championship Series/2026.md",
|
||||
"Regionals/Championship Series/2025.md",
|
||||
"Professor Work Experience.md",
|
||||
"Leagues/Next Steps After A Store Is Sanctioned.md",
|
||||
"Play! Summit/Montreal 2025/Day 1/1 - Pokemon Club.md",
|
||||
"Play! Summit/Montreal 2025/Day 1/4 - Building a Community around Pokemon.md",
|
||||
"Table of Contents.md",
|
||||
"Booster Box Cases.md",
|
||||
"Las Vegas.md",
|
||||
"Regionals/Go/Reviews - Best Practices.md",
|
||||
"thedomdomdomdom.md",
|
||||
"Regionals/Go/Selected As A HJ or AHJ - Pete Lachaine.md",
|
||||
"Regionals/Go/Roles/Pokemon Go - Head Judge.md",
|
||||
"Regionals/Go/Roles/Pokemon Go - Assistant Head Judge.md"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user