Compare commits

..

3 Commits

Author SHA1 Message Date
8775f8b1fe Refactor code for improved readability and consistency
- Updated CSRF middleware to enhance cookie value decoding.
- Reformatted OAuth proxy token store initialization for better clarity.
- Adjusted Challonge proxy router for consistent line breaks and readability.
- Enhanced OAuth router error handling and response formatting.
- Improved session router for better readability and consistency in fetching provider records.
- Refactored OAuth token store to improve key derivation logging.
- Cleaned up cookie options utility for better readability.
- Enhanced Challonge client credentials composable for consistent API calls.
- Streamlined OAuth composable for improved logging.
- Refactored main.js for better readability in session initialization.
- Improved Challonge v2.1 service error handling for better clarity.
- Cleaned up API client utility for improved readability.
- Enhanced ApiKeyManager.vue for better text formatting.
- Refactored ChallongeTest.vue for improved readability in composable usage.
2026-02-03 12:50:25 -05:00
700c1cbbbe Refactor authentication handling and improve API client security
- Updated OAuth endpoints for Challonge and Discord in platforms configuration.
- Implemented session and CSRF cookie initialization in main application entry.
- Enhanced Challonge API client to avoid sending sensitive API keys from the browser.
- Modified tournament querying to handle new state definitions and improved error handling.
- Updated UI components to reflect server-side storage of authentication tokens.
- Improved user experience in API Key Manager and Authentication Hub with clearer messaging.
- Refactored client credentials management to support asynchronous operations.
- Adjusted API client tests to validate new request configurations.
- Updated Vite configuration to support session and CSRF handling through proxies.
2026-02-03 12:50:11 -05:00
161b758a1b Add support ticket documentation and relevant attachments
- Created new markdown file for Support Ticket - 3224942 with a link to the support page.
- Added a separate markdown file for Supprt Tickets with the same link.
- Updated workspace files to include new markdown files and attachments.
- Added various attachments related to the support ticket, including images and PDFs.
2026-02-02 13:03:28 -05:00
82 changed files with 4175 additions and 1263 deletions

View File

@@ -1,5 +1,9 @@
# GitHub Copilot Instructions for Memory Palace # 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 ## 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. 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.

View File

@@ -1,251 +1,16 @@
# Pokedex Online # Pokedex.Online
A modern Vue 3 web application for exploring comprehensive Pokémon data with advanced search, filtering, and comparison tools. Built with vanilla JavaScript, Vite, and Vue Router. Pokedex.Online is a Vue + Vite frontend with a Node/Express backend for tournament and Pokémon tooling. This folder contains the runnable website code. Project documentation lives in docs/projects/Pokedex.Online.
## 🌟 Status ## Quick start
1. Install dependencies in the web root and server folder.
2. Start the backend server, then run the frontend dev server.
**✅ Production Ready** - All 106 tests passing, fully functional. Common commands:
- Frontend dev: npm run dev
- Frontend build: npm run build
- Backend dev: npm start (from server/)
See [PROGRESS.md](PROGRESS.md) for detailed development status. ## Documentation
See the docs hub for setup, deployment, and architecture details:
## Features - docs/projects/Pokedex.Online/README.md
- **Advanced Search** - Find Pokémon by name with autocomplete (optimized with Web Workers)
- **Detailed Info Cards** - Complete stats, moves, abilities, and evolution chains
- **Type Effectiveness** - Interactive matrix showing type matchups
- **Comparison Tool** - Compare multiple Pokémon side-by-side
- **Smart Filtering** - Filter by type, generation, stats, and abilities
- **Dark Mode** - Full theme support with system preference detection
- **Bookmarks** - Save favorite Pokémon for quick access
- **Responsive Design** - Optimized for mobile, tablet, and desktop
- **Fast Performance** - Lazy loading, code splitting, and efficient caching
- **Accessible** - WCAG 2.1 Level AA compliance, keyboard navigation
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- npm
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Open browser to http://localhost:5173
```
### Development Commands
```bash
# Development with hot reload
npm run dev
# Production build
npm run build
# Preview production build
npm preview
# Run tests
npm test
# Run tests with UI
npm run test:ui
# Generate coverage report
npm run test:coverage
```
2. **Option 2: Environment-based** - Create `.env` file (see Environment Setup section below) for CI/CD or shared development
### Environment Setup (Optional)
If you prefer environment variables:
```bash
# Copy environment template
cp .env.example .env
# Edit .env with your API keys
VITE_CHALLONGE_API_KEY=your_api_key_here
```
**Note**: The API Key Manager tool (`/api-key-manager`) allows you to store your key in browser localStorage, so `.env` configuration is now optional.
```bash
# Build the app
npm run build
# Preview production build
npm run preview
```
## 🐳 Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
## 📁 Project Structure
```
src/
├── components/ # Vue single-file components (.vue)
│ ├── PokemonCard.vue
│ ├── SearchBar.vue
│ ├── TypeMatrix.vue
│ └── ...
├── composables/ # Vue 3 Composition API composables
│ ├── usePokemon.js
│ ├── useSearch.js
│ ├── useFeatureFlags.js
│ └── ...
├── views/ # Page components
│ ├── HomeView.vue
│ ├── PokemonDetailView.vue
│ └── ...
├── services/ # API & data services
│ ├── pokemonService.js
│ ├── typeService.js
│ └── ...
├── utilities/ # Helper functions
│ ├── formatters.js
│ ├── validators.js
│ └── ...
├── config/ # Application configuration
│ └── constants.js
├── directives/ # Custom Vue directives
│ └── ...
├── router/ # Vue Router configuration
│ └── index.js
├── workers/ # Web Workers
│ └── search.worker.js
├── assets/ # Images, fonts, static files
├── style.css # Global styles
├── App.vue # Root component
└── main.js # Application entry point
test/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── e2e/ # End-to-end tests
```
test/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── e2e/ # End-to-end tests
```
## 🧪 Testing
Comprehensive test coverage with Vitest:
```bash
# Run tests once
npm run test:run
# Run tests in watch mode
npm test
# Open test UI
npm run test:ui
# Generate coverage report
npm run test:coverage
```
**106 tests** covering:
- Services and utilities (unit tests)
- Component integration
- User workflows
- Edge cases and error handling
## 🛠️ Tech Stack
- **Vue 3.4** - Progressive JavaScript framework with Composition API
- **Vue Router 4** - Official routing library for single-page applications
- **Vite 5** - Next-generation build tool with lightning-fast HMR
- **Vitest** - Unit testing framework
- **Vanilla JavaScript** - Latest ECMAScript features (ES2024+)
- **CSS** - Component-scoped styling
- **Web Workers** - Optimized search performance
- **Docker** - Containerization
- **nginx** - Production web server
## 🔍 Key Features Implementation
### Search Optimization
- Web Worker processes search queries without blocking UI
- Indexes all Pokémon data for instant results
- Fuzzy matching for typos and partial names
### Type Effectiveness Matrix
- Interactive table showing all type matchups
- Color-coded effectiveness levels (super effective, not very effective, etc.)
- Sortable and filterable
### State Management
- URL-based state for shareable links
- Browser localStorage for preferences
- Session storage for temporary data
### Performance
- Code splitting for faster initial load
- Lazy loading for images with placeholder
- Service worker caching strategy
- Minified production build (~350KB total)
## 📊 Development Metrics
- **Test Coverage**: 106 tests, 100% passing
- **Build Time**: ~620ms
- **Bundle Size**: 257KB (gzipped: 92.6KB)
- **Accessibility**: WCAG 2.1 Level AA
- **Performance**: 95+/100 Lighthouse score
## 🔒 Security
- No sensitive data stored in code
- Environment variables for configuration
- Content Security Policy headers
- XSS protection via Vue's template escaping
- CSRF tokens for API requests
## 📚 Documentation
- [PROGRESS.md](./PROGRESS.md) - Development status and completion details
- [Vue 3 Docs](https://vuejs.org/)
- [Vue Router Docs](https://router.vuejs.org/)
- [Vite Docs](https://vitejs.dev)
- [Vitest Docs](https://vitest.dev)
## 🤝 Contributing
1. Create a feature branch
2. Make your changes
3. Write tests for new functionality
4. Run `npm test` to verify
5. Submit a pull request
## 📝 Development Notes
This project demonstrates:
- Modern Vue 3 patterns (Composition API, composables)
- Vanilla JavaScript with latest ECMAScript features
- Performance optimization techniques (Web Workers, code splitting)
- Comprehensive test coverage (106 tests)
- Professional project structure
- Production-ready deployment

View File

@@ -65,43 +65,6 @@ server {
proxy_buffers 8 4k; 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 # Health check endpoint
location /health { location /health {
access_log off; access_log off;

View File

@@ -1774,6 +1774,12 @@
"node": ">=8" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2097,6 +2103,25 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -2352,6 +2377,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/editorconfig": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
@@ -3565,6 +3599,55 @@
"node": ">=18" "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": { "node_modules/kuler": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -3595,6 +3678,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/logform": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -4561,7 +4686,6 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -5892,10 +6016,12 @@
"name": "pokedex-online-server", "name": "pokedex-online-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },

File diff suppressed because one or more lines are too long

View 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();
};
}

View 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();
};
}

View File

@@ -12,34 +12,22 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import fetch from 'node-fetch'; import cookieParser from 'cookie-parser';
import gamemasterRouter from './gamemaster-api.js'; import gamemasterRouter from './gamemaster-api.js';
import { createAuthRouter } from './routes/auth.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 { validateOrExit, getConfig } from './utils/env-validator.js';
import logger, { requestLogger, errorLogger } from './utils/logger.js'; import logger, { requestLogger, errorLogger } from './utils/logger.js';
import { import {
setupGracefulShutdown, setupGracefulShutdown,
createHealthCheckMiddleware createHealthCheckMiddleware
} from './utils/graceful-shutdown.js'; } from './utils/graceful-shutdown.js';
import { sidMiddleware } from './middleware/sid.js';
async function safeParseJsonResponse(response) { import { csrfMiddleware } from './middleware/csrf.js';
const rawText = await response.text(); import { createOAuthTokenStore } from './services/oauth-token-store.js';
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
};
}
}
// Validate environment variables // Validate environment variables
validateOrExit(); validateOrExit();
@@ -49,11 +37,33 @@ const config = getConfig();
const app = express(); const app = express();
// Behind nginx reverse proxy in production
app.set('trust proxy', 1);
// Middleware // 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(express.json());
app.use(requestLogger); 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) // Mount API routes (nginx strips /api/ prefix before forwarding)
app.use('/gamemaster', gamemasterRouter); app.use('/gamemaster', gamemasterRouter);
app.use( app.use(
@@ -64,235 +74,22 @@ app.use(
}) })
); );
/** // Session + CSRF helpers
* Exchange authorization code for access token app.use('/session', createSessionRouter({ config, tokenStore }));
* POST /oauth/token
* Supports multiple providers: Challonge, Discord
*/
app.post('/oauth/token', async (req, res) => {
const { code, provider = 'challonge' } = req.body;
if (!code) { // Provider OAuth (server-owned tokens; browser never receives access/refresh tokens)
logger.warn('OAuth token request missing authorization code'); app.use(
return res.status(400).json({ error: 'Missing authorization code' }); '/oauth',
} csrfMiddleware({
requireOriginCheck: config.isProduction,
allowedOrigin: config.cors.origin
})
);
app.use('/oauth', createOAuthRouter({ config, tokenStore }));
try { // Provider API proxies (no split brain)
// Handle Discord OAuth app.use('/challonge', createChallongeProxyRouter({ config, tokenStore }));
if (provider === 'discord') { app.use('/discord', createDiscordRouter({ tokenStore }));
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
})
});
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
});
}
});
/** /**
* Health check endpoint (with graceful shutdown support) * Health check endpoint (with graceful shutdown support)

View File

@@ -6,9 +6,9 @@
"main": "oauth-proxy.js", "main": "oauth-proxy.js",
"scripts": { "scripts": {
"start": "node oauth-proxy.js", "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'", "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": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"lint": "echo 'Add ESLint when ready'", "lint": "echo 'Add ESLint when ready'",
@@ -16,8 +16,10 @@
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"cookie-parser": "^1.4.6",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"winston": "^3.11.0" "winston": "^3.11.0"

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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
};
}

View 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');
}

View File

@@ -73,6 +73,36 @@ const REQUIRED_ENV_VARS = {
: null : 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 OAuth (optional)
CHALLONGE_CLIENT_ID: { CHALLONGE_CLIENT_ID: {
required: false, required: false,
@@ -217,6 +247,10 @@ export function getConfig() {
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production' 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 User Permissions
discord: { discord: {
adminUsers: process.env.DISCORD_ADMIN_USERS adminUsers: process.env.DISCORD_ADMIN_USERS

View File

@@ -139,7 +139,9 @@ const isOpen = ref(false);
// 2. JWT authenticated users with developer_tools.view permission // 2. JWT authenticated users with developer_tools.view permission
// 3. Discord authenticated users with developer_tools.view permission // 3. Discord authenticated users with developer_tools.view permission
const isAvailable = computed(() => { 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 // Check JWT auth permissions
const hasJwtPermission = user.value?.permissions?.includes( const hasJwtPermission = user.value?.permissions?.includes(
@@ -154,7 +156,7 @@ const isAvailable = computed(() => {
return isDev || hasPermission; return isDev || hasPermission;
}); });
const nodeEnv = computed(() => process.env.NODE_ENV || 'unknown'); const nodeEnv = computed(() => import.meta.env.MODE || 'unknown');
const appVersion = computed( const appVersion = computed(
() => import.meta.env.VITE_APP_VERSION || '1.0.0-dev' () => import.meta.env.VITE_APP_VERSION || '1.0.0-dev'
); );

View File

@@ -1,58 +1,57 @@
/** /**
* useChallongeApiKey Composable * useChallongeApiKey Composable
* Manages Challonge API key storage in browser localStorage * Manages Challonge API key storage on the backend per-session (SID cookie)
* Works on mobile, desktop, and tablets * No-split-brain: API key never lives in browser storage.
*/ */
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { apiClient } from '../utilities/api-client.js';
const STORAGE_KEY = 'challonge_api_key'; const status = ref(null);
const storedKey = ref(getStoredKey()); const loading = ref(false);
const error = ref(null);
/** /**
* Get API key from localStorage * Save API key server-side (per-session)
* @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
* @param {string} apiKey - The API key to store * @param {string} apiKey - The API key to store
* @returns {boolean} Success status * @returns {Promise<boolean>} Success status
*/ */
function saveApiKey(apiKey) { async function saveApiKey(apiKey) {
try { if (!apiKey || typeof apiKey !== 'string') {
if (!apiKey || typeof apiKey !== 'string') { error.value = 'Invalid API key format';
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);
return false; 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 * Clear API key server-side (per-session)
* @returns {boolean} Success status * @returns {Promise<boolean>} Success status
*/ */
function clearApiKey() { async function clearApiKey() {
loading.value = true;
error.value = null;
try { try {
localStorage.removeItem(STORAGE_KEY); const data = await apiClient.post('/oauth/challonge/api-key/clear', {});
storedKey.value = null; status.value = data;
return true; return true;
} catch (error) { } catch (err) {
console.error('Failed to clear API key:', error); error.value = err.message;
return false; return false;
} finally {
loading.value = false;
} }
} }
@@ -62,34 +61,54 @@ function clearApiKey() {
* @returns {string|null} Masked key or null * @returns {string|null} Masked key or null
*/ */
const maskedKey = computed(() => { const maskedKey = computed(() => {
if (!storedKey.value) return null; if (!isKeyStored.value) return null;
const key = storedKey.value; return 'stored on server';
if (key.length < 8) return '••••••••';
return `${key.slice(0, 4)}•••••••${key.slice(-4)}`;
}); });
// Backwards-compat for older views: truthy string when stored
const storedKey = computed(() => maskedKey.value);
/** /**
* Check if API key is stored * Check if API key is stored
* @returns {boolean} True if key exists * @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) * Get the full API key (use with caution)
* @returns {string|null} Full API key or null * @returns {string|null} Full API key or null
*/ */
function getApiKey() { 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() { export function useChallongeApiKey() {
// Fire-and-forget initial status load
if (!status.value && !loading.value) {
refreshStatus();
}
return { return {
saveApiKey, saveApiKey,
clearApiKey, clearApiKey,
getApiKey, getApiKey,
getStoredKey, refreshStatus,
storedKey: computed(() => storedKey.value), status: computed(() => status.value),
loading: computed(() => loading.value),
error: computed(() => error.value),
maskedKey, maskedKey,
storedKey,
isKeyStored isKeyStored
}; };
} }

View File

@@ -37,9 +37,8 @@ export function useChallongeClient(options = {}) {
const { debug = false } = options; const { debug = false } = options;
// Get authentication sources // Get authentication sources
const { getApiKey } = useChallongeApiKey(); const { isKeyStored } = useChallongeApiKey();
const { isAuthenticated: isOAuthAuthenticated, accessToken: oauthToken } = const { isAuthenticated: isOAuthAuthenticated } = useChallongeOAuth();
useChallongeOAuth();
const { const {
isAuthenticated: isClientCredsAuthenticated, isAuthenticated: isClientCredsAuthenticated,
accessToken: clientCredsToken accessToken: clientCredsToken
@@ -50,13 +49,13 @@ export function useChallongeClient(options = {}) {
const tournamentScope = ref(ScopeType.USER); const tournamentScope = ref(ScopeType.USER);
const debugMode = ref(debug); const debugMode = ref(debug);
// Reactive API key // No-split-brain: raw keys/tokens are never available in the browser
const apiKey = computed(() => getApiKey()); const apiKey = computed(() => null);
// Masked API key for display // Masked API key for display
const maskedApiKey = computed(() => { const maskedApiKey = computed(() => {
if (!apiKey.value) return ''; if (!isKeyStored.value) return '';
return apiKey.value.slice(0, 4) + '•••••••' + apiKey.value.slice(-4); return 'stored on server';
}); });
/** /**
@@ -65,8 +64,8 @@ export function useChallongeClient(options = {}) {
const client = computed(() => { const client = computed(() => {
if (apiVersion.value === 'v1') { if (apiVersion.value === 'v1') {
// v1 only supports API key // v1 only supports API key
if (!apiKey.value) return null; if (!isKeyStored.value) return null;
return createChallongeV1Client(apiKey.value); return createChallongeV1Client(null);
} else { } else {
// v2.1 supports OAuth, client credentials, and API key // v2.1 supports OAuth, client credentials, and API key
// Smart priority based on scope selection: // Smart priority based on scope selection:
@@ -75,7 +74,7 @@ export function useChallongeClient(options = {}) {
if (tournamentScope.value === ScopeType.APPLICATION) { if (tournamentScope.value === ScopeType.APPLICATION) {
// APPLICATION scope - prefer client credentials // APPLICATION scope - prefer client credentials
if (isClientCredsAuthenticated.value && clientCredsToken.value) { if (isClientCredsAuthenticated.value) {
if (debugMode.value) { if (debugMode.value) {
console.log( console.log(
'🔐 Using Client Credentials token for APPLICATION scope' '🔐 Using Client Credentials token for APPLICATION scope'
@@ -85,43 +84,38 @@ export function useChallongeClient(options = {}) {
{ token: clientCredsToken.value, type: AuthType.OAUTH }, { token: clientCredsToken.value, type: AuthType.OAUTH },
{ debug: debugMode.value } { 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 { } else {
// USER scope - prefer OAuth user tokens or API key // USER scope - prefer OAuth user tokens or API key
if (isOAuthAuthenticated.value && oauthToken.value) { if (isOAuthAuthenticated.value) {
if (debugMode.value) { if (debugMode.value) {
console.log('🔐 Using OAuth user token for USER scope'); console.log('🔐 Using OAuth user token for USER scope');
} }
return createChallongeV2Client( return createChallongeV2Client(
{ token: oauthToken.value, type: AuthType.OAUTH }, { token: null, type: AuthType.OAUTH },
{ debug: debugMode.value } { debug: debugMode.value }
); );
} else if (apiKey.value) { } else if (isKeyStored.value) {
if (debugMode.value) { if (debugMode.value) {
console.log('🔑 Using API Key for USER scope'); console.log('🔑 Using API Key for USER scope');
} }
return createChallongeV2Client( return createChallongeV2Client(
{ token: apiKey.value, type: AuthType.API_KEY }, { token: null, type: AuthType.API_KEY },
{ debug: debugMode.value } { debug: debugMode.value }
); );
} }
} }
// Fallback: try API key // Fallback: try API key
if (apiKey.value) { if (isKeyStored.value) {
if (debugMode.value) { if (debugMode.value) {
console.log('🔑 Using API Key (fallback)'); console.log('🔑 Using API Key (fallback)');
} }
return createChallongeV2Client( return createChallongeV2Client(
{ token: apiKey.value, type: AuthType.API_KEY }, { token: null, type: AuthType.API_KEY },
{ debug: debugMode.value } { debug: debugMode.value }
); );
} }
@@ -137,13 +131,14 @@ export function useChallongeClient(options = {}) {
if (apiVersion.value === 'v1') { if (apiVersion.value === 'v1') {
return 'API Key'; 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'; if (isOAuthAuthenticated.value) return 'OAuth';
} if (isKeyStored.value) return 'API Key';
return 'API Key'; return 'None';
}); });
/** /**

View File

@@ -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 * No-split-brain: client credentials and tokens are stored on the backend
* Used for APPLICATION scope access (application:manage) * per-session (SID cookie) and never returned to the browser.
*
* 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
* ```
*/ */
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { apiClient } from '../utilities/api-client.js';
const CREDENTIALS_KEY = 'challonge_client_credentials'; const status = ref(null);
const TOKEN_KEY = 'challonge_client_token';
// Shared state across all instances
const credentials = ref(null);
const tokenData = ref(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref('');
// Load credentials and token from localStorage on module initialization function secondsUntil(expiresAt) {
try { if (!expiresAt) return null;
const storedCreds = localStorage.getItem(CREDENTIALS_KEY); const diff = expiresAt - Date.now();
if (storedCreds) { return diff > 0 ? Math.floor(diff / 1000) : 0;
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);
} }
export function useChallongeClientCredentials() { export function useChallongeClientCredentials() {
const isAuthenticated = computed(() => { const method = computed(() => status.value?.methods?.client_credentials);
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 hasCredentials = computed(() => { 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(() => { const maskedClientId = computed(() => {
if (!credentials.value?.client_id) return null; if (!hasCredentials.value) return '';
const id = credentials.value.client_id; return 'stored on server';
if (id.length < 12) return id.slice(0, 4) + '••••';
return id.slice(0, 6) + '•••••••' + id.slice(-4);
}); });
/** const tokenInfo = computed(() => {
* Save client credentials to localStorage const expiresAt = method.value?.expires_at;
* @param {string} clientId - OAuth client ID return {
* @param {string} clientSecret - OAuth client secret expiresAt: expiresAt || null,
* @returns {boolean} Success status expiresIn: secondsUntil(expiresAt)
*/ };
function saveCredentials(clientId, clientSecret) { });
try {
if (!clientId || !clientSecret) {
throw new Error('Client ID and secret are required');
}
credentials.value = { async function refreshStatus() {
client_id: clientId, status.value = await apiClient.get('/oauth/challonge/status');
client_secret: clientSecret, return status.value;
saved_at: new Date().toISOString()
};
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;
}
} }
/** async function saveCredentials(clientId, clientSecret, scope) {
* 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;
}
loading.value = true; loading.value = true;
error.value = null; error.value = '';
try { try {
console.log('🔐 Requesting client credentials token...'); status.value = await apiClient.post(
console.log(' Client ID:', maskedClientId.value); '/oauth/challonge/client-credentials',
console.log(' Scope:', scope); {
clientId,
const response = await fetch('https://api.challonge.com/oauth/token', { clientSecret,
method: 'POST', scope
headers: { }
'Content-Type': 'application/x-www-form-urlencoded' );
}, return true;
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}`
);
}
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;
} catch (err) { } catch (err) {
error.value = err.message; error.value = err.message || 'Failed to save credentials';
console.error('❌ Client credentials authentication failed:', err); return false;
throw err;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/** async function authenticate(scope = 'application:manage') {
* Force token refresh loading.value = true;
* @param {string} scope - Requested scope error.value = '';
* @returns {Promise<string>} New access token try {
*/ status.value = await apiClient.post(
async function refresh(scope = 'application:manage') { '/oauth/challonge/client-credentials',
// Clear existing token { scope }
tokenData.value = null; );
localStorage.removeItem(TOKEN_KEY); return true;
} finally {
loading.value = false;
}
}
// Get new token async function refresh(scope = 'application:manage') {
return authenticate(scope); return authenticate(scope);
} }
/** async function logout() {
* Logout and clear token (keeps credentials) loading.value = true;
*/ error.value = '';
function logout() { try {
tokenData.value = null; status.value = await apiClient.post(
localStorage.removeItem(TOKEN_KEY); '/oauth/challonge/client-credentials/logout',
console.log('✅ Logged out (credentials retained)'); {}
);
return true;
} catch (err) {
error.value = err.message || 'Logout failed';
return false;
} finally {
loading.value = false;
}
} }
/** async function clearCredentials() {
* Get token info for debugging loading.value = true;
*/ error.value = '';
const tokenInfo = computed(() => { try {
if (!tokenData.value) return null; 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(); // Best-effort initial status load
const expiresAt = tokenData.value.expires_at; if (!status.value && !loading.value) {
const timeUntilExpiry = expiresAt ? expiresAt - now : 0; 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 { return {
// State
isAuthenticated,
isExpired,
accessToken,
hasCredentials, hasCredentials,
maskedClientId, maskedClientId,
loading, isAuthenticated,
error, loading: computed(() => loading.value),
error: computed({
get: () => error.value,
set: v => {
error.value = v || '';
}
}),
tokenInfo, tokenInfo,
// Actions
saveCredentials, saveCredentials,
clearCredentials, clearCredentials,
authenticate, authenticate,
refresh, refresh,
logout logout,
refreshStatus,
status: computed(() => status.value)
}; };
} }

View File

@@ -11,45 +11,71 @@
*/ */
import { ref, computed } from 'vue'; 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 CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID;
const REDIRECT_URI = const REDIRECT_URI =
import.meta.env.VITE_CHALLONGE_REDIRECT_URI || import.meta.env.VITE_CHALLONGE_REDIRECT_URI ||
`${window.location.origin}/oauth/callback`; `${window.location.origin}/oauth/callback`;
// Shared state across all instances // Shared state across all instances
const tokens = ref(null); const status = ref(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref(null);
// Load tokens from localStorage on module initialization async function refreshStatus() {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const data = await apiClient.get('/oauth/challonge/status', {
if (stored) { deduplicate: false
tokens.value = JSON.parse(stored); });
if (data) status.value = data;
// Check if token is expired } catch {
if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) { // best-effort
console.log('🔄 Token expired, will need to refresh');
}
} }
} catch (err) {
console.error('Failed to load OAuth tokens:', err);
} }
export function useChallongeOAuth() { export function useChallongeOAuth() {
const isAuthenticated = computed(() => { const isAuthenticated = computed(() => {
return !!tokens.value?.access_token; return !!status.value?.methods?.user_oauth?.connected;
}); });
const isExpired = computed(() => { const isExpired = computed(() => {
if (!tokens.value?.expires_at) return false; const expiresAt = status.value?.methods?.user_oauth?.expires_at;
return Date.now() >= tokens.value.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(() => { 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 * @returns {Object} Object with authUrl and state
*/ */
function getAuthorizationUrl( function getAuthorizationUrl(
scope = 'tournaments:read tournaments:write', scopeOrOptions = 'tournaments:read tournaments:write',
state = null state = null
) { ) {
if (!CLIENT_ID) { if (!CLIENT_ID) {
throw new Error('VITE_CHALLONGE_CLIENT_ID not configured'); 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 // Generate state if not provided
const oauthState = state || generateState(); const oauthState = state || generateState();
@@ -94,6 +125,10 @@ export function useChallongeOAuth() {
// Store state for CSRF protection // Store state for CSRF protection
sessionStorage.setItem('oauth_state', state); 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); console.log('🔐 Starting OAuth flow with state:', state);
@@ -130,44 +165,14 @@ export function useChallongeOAuth() {
error.value = null; error.value = null;
try { try {
const response = await fetch('/api/oauth/token', { await ensureCsrfCookie();
method: 'POST', const data = await apiClient.post('/oauth/challonge/exchange', { code });
headers: { status.value = data;
'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));
sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_provider');
console.log('✅ OAuth authentication successful'); console.log('✅ OAuth authentication successful');
return tokens.value; return status.value;
} catch (err) { } catch (err) {
error.value = err.message; error.value = err.message;
console.error('Token exchange error:', err); console.error('Token exchange error:', err);
@@ -181,53 +186,14 @@ export function useChallongeOAuth() {
* Refresh access token using refresh token * Refresh access token using refresh token
*/ */
async function refreshToken() { async function refreshToken() {
if (!tokens.value?.refresh_token) {
throw new Error('No refresh token available');
}
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await fetch('/api/oauth/refresh', { status.value = await apiClient.post('/oauth/challonge/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));
console.log('✅ Token refreshed successfully'); console.log('✅ Token refreshed successfully');
return tokens.value; return status.value;
} catch (err) { } catch (err) {
error.value = err.message; error.value = err.message;
console.error('Token refresh error:', err); console.error('Token refresh error:', err);
@@ -244,30 +210,20 @@ export function useChallongeOAuth() {
* Get valid access token (refreshes if expired) * Get valid access token (refreshes if expired)
*/ */
async function getValidToken() { async function getValidToken() {
if (!tokens.value) { throw new Error(
throw new Error('Not authenticated'); 'No-split-brain: Challonge OAuth token is not accessible in the browser'
} );
// 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;
} }
/** /**
* Logout and clear tokens * Logout and clear tokens
*/ */
function logout() { function logout() {
tokens.value = null; status.value = null;
localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem('oauth_state'); 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 { return {
// State // State
tokens: computed(() => tokens.value), tokens: computed(() => status.value),
isAuthenticated, isAuthenticated,
isExpired, isExpired,
expiresIn,
accessToken, accessToken,
loading: computed(() => loading.value), loading: computed(() => loading.value),
error: computed(() => error.value), error: computed(() => error.value),
@@ -299,3 +256,8 @@ export function useChallongeOAuth() {
getAuthorizationUrl getAuthorizationUrl
}; };
} }
// Fire-and-forget initial status load
if (!status.value && !loading.value) {
refreshStatus();
}

View File

@@ -105,7 +105,8 @@ export function useChallongeTests(client, apiVersion, tournamentScope) {
*/ */
async function testListTournaments(resetPagination = true) { async function testListTournaments(resetPagination = true) {
if (!client.value) { 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; return;
} }
@@ -131,13 +132,6 @@ export function useChallongeTests(client, apiVersion, tournamentScope) {
scopeType: tournamentScope.value scopeType: tournamentScope.value
}); });
console.log('📊 Tournament API Response:', {
page: currentPage.value,
perPage: perPage.value,
scope: tournamentScope.value,
resultsCount: result.length
});
totalTournaments.value = result.length; totalTournaments.value = result.length;
hasNextPage.value = result.length >= perPage.value; hasNextPage.value = result.length >= perPage.value;
return result; return result;

View File

@@ -13,6 +13,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useOAuth } from './useOAuth.js'; import { useOAuth } from './useOAuth.js';
import { apiClient } from '../utilities/api-client.js';
// Shared Discord user profile data // Shared Discord user profile data
const discordUser = ref(null); const discordUser = ref(null);
@@ -47,24 +48,7 @@ export function useDiscordOAuth() {
*/ */
async function fetchUserProfile() { async function fetchUserProfile() {
try { try {
const token = oauth.accessToken.value; const data = await apiClient.get('/discord/profile');
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();
discordUser.value = data.user; discordUser.value = data.user;
console.log(`✅ Loaded Discord profile: ${data.user.username}`); console.log(`✅ Loaded Discord profile: ${data.user.username}`);
@@ -103,9 +87,8 @@ export function useDiscordOAuth() {
* @returns {boolean} True if user has developer access * @returns {boolean} True if user has developer access
*/ */
function hasDevAccess() { function hasDevAccess() {
// Check if tokens include permissions // No-split-brain: permissions are not surfaced via OAuth token exchange anymore.
const permissions = oauth.tokens.value?.permissions || []; return false;
return permissions.includes('developer_tools.view');
} }
return { return {

View File

@@ -21,9 +21,35 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { PLATFORMS } from '../config/platforms.js'; import { PLATFORMS } from '../config/platforms.js';
import { apiClient } from '../utilities/api-client.js';
// Multi-provider token storage (shared across all instances) function getCookie(name) {
const tokenStores = new Map(); 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 * Initialize OAuth state for a provider
@@ -33,8 +59,8 @@ const tokenStores = new Map();
*/ */
function initializeProvider(provider) { function initializeProvider(provider) {
// Return existing state if already initialized // Return existing state if already initialized
if (tokenStores.has(provider)) { if (statusStores.has(provider)) {
return tokenStores.get(provider); return statusStores.get(provider);
} }
// Validate platform exists // Validate platform exists
@@ -43,35 +69,28 @@ function initializeProvider(provider) {
throw new Error(`Platform not found: ${provider}`); throw new Error(`Platform not found: ${provider}`);
} }
// Get storage key from OAuth config
const oauthConfig = platformConfig.auth.oauth; const oauthConfig = platformConfig.auth.oauth;
if (!oauthConfig?.enabled) { if (!oauthConfig?.enabled) {
throw new Error(`OAuth not enabled for ${provider}`); throw new Error(`OAuth not enabled for ${provider}`);
} }
const storageKey = oauthConfig.storageKey;
// Create provider-specific state // Create provider-specific state
const state = { const state = {
tokens: ref(null), tokens: ref(null),
loading: ref(false), loading: ref(false),
error: ref(null), error: ref(null),
provider, provider
storageKey
}; };
// Load existing tokens from localStorage on initialization // Best-effort initial status fetch
try { apiClient
const stored = localStorage.getItem(storageKey); .get(`/oauth/${provider}/status`, { deduplicate: false })
if (stored) { .then(data => {
state.tokens.value = JSON.parse(stored); if (data) state.tokens.value = data;
console.log(`✅ Loaded ${provider} OAuth tokens from storage`); })
} .catch(() => {});
} catch (err) {
console.error(`Failed to load ${provider} OAuth tokens:`, err);
}
tokenStores.set(provider, state); statusStores.set(provider, state);
return state; return state;
} }
@@ -87,26 +106,29 @@ export function useOAuth(provider = 'challonge') {
// Computed properties for token state // Computed properties for token state
const isAuthenticated = computed(() => { const isAuthenticated = computed(() => {
return !!state.tokens.value?.access_token; return !!state.tokens.value?.connected;
}); });
const isExpired = computed(() => { const isExpired = computed(() => {
if (!state.tokens.value?.expires_at) return false; const expiresAt = state.tokens.value?.expires_at;
return Date.now() >= state.tokens.value.expires_at; if (!expiresAt) return false;
return Date.now() >= expiresAt;
}); });
const expiresIn = computed(() => { const expiresIn = computed(() => {
if (!state.tokens.value?.expires_at) return null; const expiresAt = state.tokens.value?.expires_at;
const diff = state.tokens.value.expires_at - Date.now(); if (!expiresAt) return null;
const diff = expiresAt - Date.now();
return diff > 0 ? Math.floor(diff / 1000) : 0; return diff > 0 ? Math.floor(diff / 1000) : 0;
}); });
const accessToken = computed(() => { 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(() => { 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; state.error.value = null;
try { try {
// Exchange code for tokens via backend endpoint await ensureCsrfCookie();
const response = await fetch(oauthConfig.tokenEndpoint, { const data = await apiClient.post(oauthConfig.tokenEndpoint, { code });
method: 'POST', state.tokens.value = data;
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));
// Clean up session storage // Clean up session storage
sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_provider'); sessionStorage.removeItem('oauth_provider');
sessionStorage.removeItem('oauth_return_to'); sessionStorage.removeItem('oauth_return_to');
console.log( console.log(`${provider} OAuth authentication successful`);
`${provider} OAuth authentication successful, expires in ${data.expires_in}s` return data;
);
return tokens;
} catch (err) { } 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); console.error(`${provider} token exchange error:`, err);
throw err; throw new Error(message);
} finally { } finally {
state.loading.value = false; state.loading.value = false;
} }
@@ -299,54 +296,15 @@ export function useOAuth(provider = 'challonge') {
* @throws {Error} If no refresh token available or refresh fails * @throws {Error} If no refresh token available or refresh fails
*/ */
async function refreshTokenFn() { async function refreshTokenFn() {
if (!state.tokens.value?.refresh_token) {
throw new Error(`No refresh token available for ${provider}`);
}
state.loading.value = true; state.loading.value = true;
state.error.value = null; state.error.value = null;
try { try {
const response = await fetch(oauthConfig.refreshEndpoint, { const data = await apiClient.post(oauthConfig.refreshEndpoint, {});
method: 'POST', state.tokens.value = data;
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refresh_token: state.tokens.value.refresh_token,
provider
})
});
if (!response.ok) { console.log(`${provider} token refreshed`);
const errorData = await response.json().catch(() => ({})); return data;
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;
} catch (err) { } catch (err) {
state.error.value = err.message; state.error.value = err.message;
console.error(`${provider} token refresh error:`, err); console.error(`${provider} token refresh error:`, err);
@@ -367,23 +325,9 @@ export function useOAuth(provider = 'challonge') {
* @throws {Error} If not authenticated * @throws {Error} If not authenticated
*/ */
async function getValidToken() { async function getValidToken() {
if (!state.tokens.value) { throw new Error(
throw new Error(`Not authenticated with ${provider}`); `No-split-brain: ${provider} OAuth token is not accessible in the browser`
} );
// 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...`
);
await refreshTokenFn();
}
return state.tokens.value.access_token;
} }
/** /**
@@ -392,10 +336,12 @@ export function useOAuth(provider = 'challonge') {
*/ */
function logout() { function logout() {
state.tokens.value = null; state.tokens.value = null;
localStorage.removeItem(state.storageKey);
sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_provider'); sessionStorage.removeItem('oauth_provider');
sessionStorage.removeItem('oauth_return_to'); sessionStorage.removeItem('oauth_return_to');
if (oauthConfig.disconnectEndpoint) {
apiClient.post(oauthConfig.disconnectEndpoint, {}).catch(() => {});
}
console.log(`👋 ${provider} logged out`); console.log(`👋 ${provider} logged out`);
} }

View File

@@ -26,8 +26,9 @@ export const PLATFORMS = {
label: 'OAuth 2.0', label: 'OAuth 2.0',
description: 'User token authentication for v2.1 API', description: 'User token authentication for v2.1 API',
endpoint: 'https://api.challonge.com/oauth/authorize', endpoint: 'https://api.challonge.com/oauth/authorize',
tokenEndpoint: '/api/oauth/token', tokenEndpoint: '/oauth/challonge/exchange',
refreshEndpoint: '/api/oauth/refresh', refreshEndpoint: '/oauth/challonge/refresh',
disconnectEndpoint: '/oauth/challonge/disconnect',
scopes: ['tournaments:read', 'tournaments:write'], scopes: ['tournaments:read', 'tournaments:write'],
storageKey: 'challonge_oauth_tokens' storageKey: 'challonge_oauth_tokens'
}, },
@@ -52,8 +53,9 @@ export const PLATFORMS = {
label: 'OAuth 2.0', label: 'OAuth 2.0',
description: 'Verify your Discord identity', description: 'Verify your Discord identity',
endpoint: 'https://discord.com/api/oauth2/authorize', endpoint: 'https://discord.com/api/oauth2/authorize',
tokenEndpoint: '/api/oauth/token', tokenEndpoint: '/oauth/discord/exchange',
refreshEndpoint: '/api/oauth/refresh', refreshEndpoint: '/oauth/discord/refresh',
disconnectEndpoint: '/oauth/discord/disconnect',
scopes: ['identify'], scopes: ['identify'],
storageKey: 'discord_oauth_tokens', storageKey: 'discord_oauth_tokens',
userEndpoint: 'https://discord.com/api/users/@me' userEndpoint: 'https://discord.com/api/users/@me'

View File

@@ -20,4 +20,14 @@ app.use(router);
app.use(VueVirtualScroller); app.use(VueVirtualScroller);
app.directive('highlight', vHighlight); 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');
}
})();

View File

@@ -37,7 +37,8 @@ export function createChallongeV1Client(apiKey) {
? endpoint.slice(1) ? endpoint.slice(1)
: endpoint; : endpoint;
const url = new URL(`${baseURL}${cleanEndpoint}`, window.location.origin); 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) { if (options.params) {
Object.entries(options.params).forEach(([key, value]) => { Object.entries(options.params).forEach(([key, value]) => {

View File

@@ -50,14 +50,10 @@ export const ScopeType = {
* @returns {Object} API client with methods * @returns {Object} API client with methods
*/ */
export function createChallongeV2Client(auth, options = {}) { 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 { communityId: defaultCommunityId, debug = false } = options;
const baseURL = getBaseURL(); const baseURL = getBaseURL();
if (!token) {
throw new Error('Authentication token is required');
}
// Request tracking for debug mode // Request tracking for debug mode
let requestCount = 0; let requestCount = 0;
@@ -109,16 +105,15 @@ export function createChallongeV2Client(auth, options = {}) {
...headers ...headers
}; };
// Add authorization header // No-split-brain: never send Challonge tokens from the browser.
if (type === AuthType.OAUTH) { // Backend proxy derives auth from the per-session SID cookie and the Authorization-Type hint.
requestHeaders['Authorization'] = `Bearer ${token}`; // (Token is intentionally ignored here.)
} else {
requestHeaders['Authorization'] = token;
}
const fetchOptions = { const fetchOptions = {
method, method,
headers: requestHeaders headers: requestHeaders,
credentials: 'include',
cache: 'no-store'
}; };
if (body && method !== 'GET') { if (body && method !== 'GET') {
@@ -149,13 +144,28 @@ export function createChallongeV2Client(auth, options = {}) {
return null; return null;
} }
// Parse response body (prefer JSON when declared)
const contentType = response.headers.get('content-type') || '';
let data; let data;
try { try {
data = await response.json(); 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) { } 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); console.error('[Challonge v2.1 JSON Parse Error]', parseError);
}
const error = new Error( const error = new Error(
`HTTP ${response.status}: Failed to parse response` `HTTP ${response.status}: Failed to parse response`
); );
@@ -186,14 +196,25 @@ export function createChallongeV2Client(auth, options = {}) {
.join('\n'); .join('\n');
const error = new Error(errorMessage); const error = new Error(errorMessage);
error.status = response.status;
error.errors = errorDetails; error.errors = errorDetails;
error.response = data; error.response = data;
throw error; throw error;
} }
// Handle non-JSON:API error format // Handle non-JSON:API error format
const error = new Error( const messageFromBody =
`HTTP ${response.status}: ${data.message || response.statusText}` 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.status = response.status;
error.response = data; error.response = data;
throw error; throw error;

View File

@@ -15,6 +15,19 @@
const activeRequests = new Map(); 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 * Create an API client with configuration
* @param {Object} config - Client configuration * @param {Object} config - Client configuration
@@ -68,13 +81,31 @@ export function createApiClient(config = {}) {
async function makeRequest(url, options) { async function makeRequest(url, options) {
const { retries = maxRetries, ...fetchOptions } = options; const { retries = maxRetries, ...fetchOptions } = options;
const method = (fetchOptions.method || 'GET').toUpperCase();
// Merge headers // Merge headers
const headers = { const headers = {
'Content-Type': 'application/json',
...defaultHeaders, ...defaultHeaders,
...fetchOptions.headers ...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 // Create abort controller for timeout
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -88,6 +119,8 @@ export function createApiClient(config = {}) {
let requestOptions = { let requestOptions = {
...fetchOptions, ...fetchOptions,
headers, headers,
credentials: fetchOptions.credentials || 'include',
cache: fetchOptions.cache || 'no-store',
signal: controller.signal signal: controller.signal
}; };
if (onRequest) { if (onRequest) {
@@ -127,6 +160,14 @@ export function createApiClient(config = {}) {
throw error; 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 // Parse response
const contentType = processedResponse.headers.get('content-type'); const contentType = processedResponse.headers.get('content-type');
if (contentType?.includes('application/json')) { if (contentType?.includes('application/json')) {

View File

@@ -39,17 +39,9 @@ export async function queryAllTournaments(client, options = {}) {
communityId, communityId,
page = 1, page = 1,
per_page = 25, per_page = 25,
states = [ // Challonge v2.1 tournament list supports these canonical states.
'pending', // (Older v1-style states like "checking_in" are not accepted.)
'checking_in', states = ['pending', 'in_progress', 'ended'],
'checked_in',
'accepting_predictions',
'group_stages_underway',
'group_stages_finalized',
'underway',
'awaiting_review',
'complete'
],
includeCommunities = false includeCommunities = false
} = options; } = options;
@@ -61,6 +53,8 @@ export async function queryAllTournaments(client, options = {}) {
per_page per_page
}; };
let firstAuthError = null;
// Query all states in parallel // Query all states in parallel
const promises = states.map(state => const promises = states.map(state =>
client.tournaments client.tournaments
@@ -69,6 +63,10 @@ export async function queryAllTournaments(client, options = {}) {
state state
}) })
.catch(err => { .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); console.error(`Error querying ${state} tournaments:`, err);
return []; return [];
}) })
@@ -77,6 +75,16 @@ export async function queryAllTournaments(client, options = {}) {
// Wait for all requests // Wait for all requests
const results = await Promise.all(promises); 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 // Flatten and deduplicate by tournament ID
const tournamentMap = new Map(); const tournamentMap = new Map();
results.forEach(tournamentArray => { results.forEach(tournamentArray => {

View File

@@ -94,15 +94,16 @@
<ul> <ul>
<li> <li>
<strong>Secure Storage:</strong> Your API key is stored locally in <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>
<li> <li>
<strong>Device Specific:</strong> Each device/browser has its own <strong>Session Scoped:</strong> Each browser session has its own
storage. The key won't sync across devices. credentials. Reconnect if you clear cookies or switch browsers.
</li> </li>
<li> <li>
<strong>Persistent:</strong> Your key will be available whenever you <strong>Short-lived by design:</strong> Credentials are stored for
use this app, even after closing the browser. convenience during development, but can be cleared anytime.
</li> </li>
<li> <li>
<strong>Clear Anytime:</strong> Use the "Clear Stored Key" button to <strong>Clear Anytime:</strong> Use the "Clear Stored Key" button to
@@ -119,9 +120,8 @@
<div class="section warning-section"> <div class="section warning-section">
<h2>🔒 Security Notice</h2> <h2>🔒 Security Notice</h2>
<p> <p>
⚠️ <strong>localStorage is not encrypted.</strong> Only use this on Only use this on trusted devices. If you're on a shared or public
trusted devices. If you're on a shared or public computer, clear your computer, clear your API key when done.
API key when done.
</p> </p>
<p> <p>
For production use, consider using a backend proxy that handles API For production use, consider using a backend proxy that handles API

View File

@@ -105,7 +105,7 @@
</span> </span>
</div> </div>
<p class="method-description"> <p class="method-description">
User token authentication for v2.1 API (APPLICATION scope) User OAuth for v2.1 API (USER scope)
</p> </p>
<div v-if="isChallongeOAuthAuthenticated" class="token-info"> <div v-if="isChallongeOAuthAuthenticated" class="token-info">
@@ -319,8 +319,8 @@
<!-- Footer --> <!-- Footer -->
<div class="footer"> <div class="footer">
<p> <p>
Your authentication tokens are stored securely in your browser's local Your authentication tokens are stored server-side for your current
storage. session (linked via an httpOnly cookie).
</p> </p>
<router-link to="/" class="btn-link"> Back Home</router-link> <router-link to="/" class="btn-link"> Back Home</router-link>
</div> </div>
@@ -359,10 +359,8 @@ const isChallongeOAuthAuthenticated = computed(
); );
const challongeOAuthExpiresIn = computed(() => challongeOAuth.expiresIn.value); const challongeOAuthExpiresIn = computed(() => challongeOAuth.expiresIn.value);
const challongeOAuthRefreshedAt = computed(() => { const challongeOAuthRefreshedAt = computed(() => {
return ( // No-split-brain: we don't surface token metadata to the browser
challongeOAuth.tokens.value?.refreshed_at || return null;
challongeOAuth.tokens.value?.created_at
);
}); });
// Challonge Client Credentials // Challonge Client Credentials
@@ -392,9 +390,12 @@ const discordExpiresIn = computed(() => discord.expiresIn.value);
const platforms = computed(() => getAllPlatforms()); const platforms = computed(() => getAllPlatforms());
// Methods // Methods
function saveChallongeApiKey() { async function saveChallongeApiKey() {
try { try {
saveApiKey(newChallongeApiKey.value); const ok = await saveApiKey(newChallongeApiKey.value);
if (!ok) {
throw new Error('Failed to save API key');
}
newChallongeApiKey.value = ''; newChallongeApiKey.value = '';
successMessage.value = 'Challonge API key saved successfully!'; successMessage.value = 'Challonge API key saved successfully!';
setTimeout(() => (successMessage.value = ''), 3000); 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.')) { if (confirm('Are you sure? This will remove your API key.')) {
clearApiKey(); await clearApiKey();
successMessage.value = 'Challonge API key deleted'; successMessage.value = 'Challonge API key deleted';
setTimeout(() => (successMessage.value = ''), 3000); setTimeout(() => (successMessage.value = ''), 3000);
} }
@@ -446,12 +447,15 @@ function disconnectChallongeOAuth() {
} }
} }
function saveChallongeClientCredentials() { async function saveChallongeClientCredentials() {
try { try {
challengeClientCreds.saveCredentials( const ok = await challengeClientCreds.saveCredentials(
newClientId.value, newClientId.value,
newClientSecret.value newClientSecret.value
); );
if (!ok) {
throw new Error('Failed to save client credentials');
}
newClientId.value = ''; newClientId.value = '';
newClientSecret.value = ''; newClientSecret.value = '';
successMessage.value = 'Client credentials saved!'; successMessage.value = 'Client credentials saved!';
@@ -461,9 +465,9 @@ function saveChallongeClientCredentials() {
} }
} }
function deleteChallongeClientCredentials() { async function deleteChallongeClientCredentials() {
if (confirm('Delete client credentials?')) { if (confirm('Delete client credentials?')) {
challengeClientCreds.clearCredentials(); await challengeClientCreds.clearCredentials();
successMessage.value = 'Client credentials deleted'; successMessage.value = 'Client credentials deleted';
setTimeout(() => (successMessage.value = ''), 3000); setTimeout(() => (successMessage.value = ''), 3000);
} }

View File

@@ -10,7 +10,7 @@
</p> </p>
<!-- API Version & Settings Controls --> <!-- API Version & Settings Controls -->
<div v-if="apiKey" class="section controls-section"> <div v-if="hasAnyAuth" class="section controls-section">
<div class="controls-grid"> <div class="controls-grid">
<!-- Authentication Settings Link --> <!-- Authentication Settings Link -->
<div class="control-group info-section"> <div class="control-group info-section">
@@ -40,34 +40,44 @@
</div> </div>
<!-- No API Key Stored --> <!-- 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"> <div class="warning-content">
<h2> No API Key Found</h2> <h2> No Challonge Authentication Found</h2>
<p class="warning-text"> <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. started.
</p> </p>
<router-link to="/api-key-manager" class="btn btn-primary btn-lg"> <div class="button-row">
Go to API Key Manager <router-link to="/auth" class="btn btn-primary btn-lg">
</router-link> 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"> <p class="hint-text">
The API Key Manager securely stores your key in your browser. Set it This app stores your Challonge credentials server-side for the
up once and use it across all tools. current session (no tokens/keys in browser storage).
</p> </p>
</div> </div>
</div> </div>
<!-- List Tournaments Test --> <!-- List Tournaments Test -->
<div v-if="apiKey" class="section"> <div v-if="hasAnyAuth" class="section">
<h2>Test API Connection</h2> <h2>Test API Connection</h2>
<button <button
@click="testListTournaments()" @click="testListTournaments()"
:disabled="loading" :disabled="loading || needsClientCredsForApplication"
class="btn btn-primary" class="btn btn-primary"
> >
{{ loading ? 'Loading...' : 'List My Tournaments' }} {{ loading ? 'Loading...' : 'List My Tournaments' }}
</button> </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="error" class="error-message">{{ error }}</div>
<div v-if="paginationInfo" class="pagination-info"> <div v-if="paginationInfo" class="pagination-info">
{{ paginationInfo }} {{ paginationInfo }}
@@ -99,13 +109,13 @@
</div> </div>
<!-- Configuration Instructions --> <!-- Configuration Instructions -->
<div v-if="!apiKey" class="section info-section"> <div v-if="!hasAnyAuth" class="section info-section">
<h2> How to Set Up Your API Key</h2> <h2> How to Set Up Challonge Authentication</h2>
<div class="info-steps"> <div class="info-steps">
<div class="step"> <div class="step">
<div class="step-number">1</div> <div class="step-number">1</div>
<div class="step-content"> <div class="step-content">
<h3>Get Your API Key</h3> <h3>Get Your API Key (optional)</h3>
<p> <p>
Visit Visit
<a <a
@@ -121,10 +131,10 @@
<div class="step"> <div class="step">
<div class="step-number">2</div> <div class="step-number">2</div>
<div class="step-content"> <div class="step-content">
<h3>Store in API Key Manager</h3> <h3>Store via the app</h3>
<p> <p>
Go to the API Key Manager and paste your key. It will be saved Use Authentication Settings (OAuth / Client Credentials) or the
securely in your browser. API Key Manager (API key).
</p> </p>
</div> </div>
</div> </div>
@@ -156,11 +166,26 @@ import TournamentGrid from '../components/challonge/TournamentGrid.vue';
import TournamentDetail from '../components/challonge/TournamentDetail.vue'; import TournamentDetail from '../components/challonge/TournamentDetail.vue';
import { ScopeType } from '../services/challonge.service.js'; import { ScopeType } from '../services/challonge.service.js';
const { getApiKey } = useChallongeApiKey(); const { isKeyStored } = useChallongeApiKey();
const apiKey = computed(() => getApiKey());
const maskedApiKey = computed(() => { const maskedApiKey = computed(() => {
if (!apiKey.value) return ''; if (!isKeyStored.value) return '';
return apiKey.value.slice(0, 4) + '•••••••' + apiKey.value.slice(-4); 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 // OAuth Management
@@ -178,7 +203,7 @@ const {
accessToken: clientCredsToken accessToken: clientCredsToken
} = useChallongeClientCredentials(); } = useChallongeClientCredentials();
// API Configuration // API Configuration (driven by unified client)
const apiVersion = ref('v2.1'); const apiVersion = ref('v2.1');
const tournamentScope = ref(ScopeType.USER); const tournamentScope = ref(ScopeType.USER);
const perPage = ref(100); const perPage = ref(100);
@@ -191,25 +216,26 @@ const apiKeyCollapsed = ref(false);
const oauthCollapsed = ref(false); const oauthCollapsed = ref(false);
const clientCredsCollapsed = 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 { const {
client, client,
apiVersion: clientApiVersion, apiVersion: clientApiVersion,
tournamentScope: clientTournamentScope, tournamentScope: clientTournamentScope,
maskedApiKey: clientMaskedApiKey, maskedApiKey: clientMaskedApiKey,
authType authType
} = useChallongeClient( } = useChallongeClient({
apiKey, debug: localStorage.getItem('DEBUG_CHALLONGE') === 'true'
apiVersion, });
tournamentScope,
{ // Keep existing local controls bound to the composable state
oauthToken: accessToken, apiVersion.value = clientApiVersion.value;
oauthAuthenticated: isAuthenticated, tournamentScope.value = clientTournamentScope.value;
clientCredsToken: clientCredsToken, watch(apiVersion, v => {
clientCredsAuthenticated: isClientCredsAuthenticated clientApiVersion.value = v;
}, });
debugMode watch(tournamentScope, v => {
); clientTournamentScope.value = v;
});
// Initialize Tournament Tests (replaces ~200 lines of tournament logic) // Initialize Tournament Tests (replaces ~200 lines of tournament logic)
const { const {

View File

@@ -184,8 +184,8 @@
it to version control it to version control
</li> </li>
<li> <li>
🔐 Credentials are stored in your browser's localStorage (not sent 🔐 Credentials are stored server-side for your current session
to any server) (linked via an httpOnly cookie)
</li> </li>
<li> <li>
Only use on <strong>trusted devices</strong> - Clear Only use on <strong>trusted devices</strong> - Clear
@@ -373,7 +373,7 @@ async function handleSaveCredentials() {
try { try {
// Save credentials // Save credentials
const success = saveCredentials( const success = await saveCredentials(
inputClientId.value.trim(), inputClientId.value.trim(),
inputClientSecret.value.trim() inputClientSecret.value.trim()
); );
@@ -440,18 +440,19 @@ function handleLogout() {
} }
function handleDeleteCredentials() { function handleDeleteCredentials() {
const success = clearCredentials(); clearCredentials().then(success => {
if (success) { if (success) {
showDeleteConfirm.value = false; showDeleteConfirm.value = false;
inputClientId.value = ''; inputClientId.value = '';
inputClientSecret.value = ''; inputClientSecret.value = '';
successMessage.value = 'Client credentials cleared successfully'; successMessage.value = 'Client credentials cleared successfully';
setTimeout(() => { setTimeout(() => {
successMessage.value = ''; successMessage.value = '';
}, 3000); }, 3000);
} else { } else {
error.value = 'Failed to clear client credentials'; error.value = 'Failed to clear client credentials';
} }
});
} }
</script> </script>

View File

@@ -9,7 +9,8 @@ import { ScopeType } from '../../../src/services/challonge.service.js';
// Mock dependencies before importing composable // Mock dependencies before importing composable
vi.mock('../../../src/composables/useChallongeApiKey.js', () => ({ vi.mock('../../../src/composables/useChallongeApiKey.js', () => ({
useChallongeApiKey: () => ({ useChallongeApiKey: () => ({
getApiKey: () => 'test-api-key-1234567890' isKeyStored: ref(true),
getApiKey: () => null
}) })
})); }));
@@ -91,10 +92,7 @@ describe('useChallongeClient', () => {
it('masks API key correctly', () => { it('masks API key correctly', () => {
const { maskedApiKey } = useChallongeClient(); const { maskedApiKey } = useChallongeClient();
const masked = maskedApiKey.value; expect(maskedApiKey.value).toBe('stored on server');
expect(masked).toContain('•••');
expect(masked.startsWith('test')).toBe(true);
expect(masked.endsWith('7890')).toBe(true);
}); });
it('returns auth type', () => { it('returns auth type', () => {

View File

@@ -59,9 +59,7 @@ describe('api-client', () => {
'/test', '/test',
expect.objectContaining({ expect.objectContaining({
method: 'GET', method: 'GET',
headers: expect.objectContaining({ credentials: 'include'
'Content-Type': 'application/json'
})
}) })
); );
}); });

View File

@@ -36,12 +36,28 @@ export default defineConfig({
port: 5173, port: 5173,
strictPort: true, // Fail if port is already in use instead of trying next available port strictPort: true, // Fail if port is already in use instead of trying next available port
proxy: { 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 v1 proxy (legacy)
'/api/challonge/v1': { '/api/challonge/v1': {
target: 'https://api.challonge.com/v1', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api\/challonge\/v1/, ''), rewrite: path => path.replace(/^\/api/, ''),
secure: true, secure: false,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -49,10 +65,10 @@ export default defineConfig({
}, },
// API v2.1 proxy (current) // API v2.1 proxy (current)
'/api/challonge/v2.1': { '/api/challonge/v2.1': {
target: 'https://api.challonge.com/v2.1', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api\/challonge\/v2\.1/, ''), rewrite: path => path.replace(/^\/api/, ''),
secure: true, secure: false,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/vnd.api+json' 'Content-Type': 'application/vnd.api+json'
@@ -69,7 +85,23 @@ export default defineConfig({
'/api/gamemaster': { '/api/gamemaster': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, 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 secure: false
} }
} }

View File

@@ -0,0 +1,251 @@
# Pokedex Online
A modern Vue 3 web application for exploring comprehensive Pokémon data with advanced search, filtering, and comparison tools. Built with vanilla JavaScript, Vite, and Vue Router.
## 🌟 Status
**✅ Production Ready** - All 106 tests passing, fully functional.
See [PROGRESS.md](PROGRESS.md) for detailed development status.
## Features
- **Advanced Search** - Find Pokémon by name with autocomplete (optimized with Web Workers)
- **Detailed Info Cards** - Complete stats, moves, abilities, and evolution chains
- **Type Effectiveness** - Interactive matrix showing type matchups
- **Comparison Tool** - Compare multiple Pokémon side-by-side
- **Smart Filtering** - Filter by type, generation, stats, and abilities
- **Dark Mode** - Full theme support with system preference detection
- **Bookmarks** - Save favorite Pokémon for quick access
- **Responsive Design** - Optimized for mobile, tablet, and desktop
- **Fast Performance** - Lazy loading, code splitting, and efficient caching
- **Accessible** - WCAG 2.1 Level AA compliance, keyboard navigation
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- npm
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Open browser to http://localhost:5173
```
### Development Commands
```bash
# Development with hot reload
npm run dev
# Production build
npm run build
# Preview production build
npm preview
# Run tests
npm test
# Run tests with UI
npm run test:ui
# Generate coverage report
npm run test:coverage
```
2. **Option 2: Environment-based** - Create `.env` file (see Environment Setup section below) for CI/CD or shared development
### Environment Setup (Optional)
If you prefer environment variables:
```bash
# Copy environment template
cp .env.example .env
# Edit .env with your API keys
VITE_CHALLONGE_API_KEY=your_api_key_here
```
**Note**: The API Key Manager tool (`/api-key-manager`) allows you to store your key in browser localStorage, so `.env` configuration is now optional.
```bash
# Build the app
npm run build
# Preview production build
npm run preview
```
## 🐳 Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
## 📁 Project Structure
```
src/
├── components/ # Vue single-file components (.vue)
│ ├── PokemonCard.vue
│ ├── SearchBar.vue
│ ├── TypeMatrix.vue
│ └── ...
├── composables/ # Vue 3 Composition API composables
│ ├── usePokemon.js
│ ├── useSearch.js
│ ├── useFeatureFlags.js
│ └── ...
├── views/ # Page components
│ ├── HomeView.vue
│ ├── PokemonDetailView.vue
│ └── ...
├── services/ # API & data services
│ ├── pokemonService.js
│ ├── typeService.js
│ └── ...
├── utilities/ # Helper functions
│ ├── formatters.js
│ ├── validators.js
│ └── ...
├── config/ # Application configuration
│ └── constants.js
├── directives/ # Custom Vue directives
│ └── ...
├── router/ # Vue Router configuration
│ └── index.js
├── workers/ # Web Workers
│ └── search.worker.js
├── assets/ # Images, fonts, static files
├── style.css # Global styles
├── App.vue # Root component
└── main.js # Application entry point
test/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── e2e/ # End-to-end tests
```
test/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── e2e/ # End-to-end tests
```
## 🧪 Testing
Comprehensive test coverage with Vitest:
```bash
# Run tests once
npm run test:run
# Run tests in watch mode
npm test
# Open test UI
npm run test:ui
# Generate coverage report
npm run test:coverage
```
**106 tests** covering:
- Services and utilities (unit tests)
- Component integration
- User workflows
- Edge cases and error handling
## 🛠️ Tech Stack
- **Vue 3.4** - Progressive JavaScript framework with Composition API
- **Vue Router 4** - Official routing library for single-page applications
- **Vite 5** - Next-generation build tool with lightning-fast HMR
- **Vitest** - Unit testing framework
- **Vanilla JavaScript** - Latest ECMAScript features (ES2024+)
- **CSS** - Component-scoped styling
- **Web Workers** - Optimized search performance
- **Docker** - Containerization
- **nginx** - Production web server
## 🔍 Key Features Implementation
### Search Optimization
- Web Worker processes search queries without blocking UI
- Indexes all Pokémon data for instant results
- Fuzzy matching for typos and partial names
### Type Effectiveness Matrix
- Interactive table showing all type matchups
- Color-coded effectiveness levels (super effective, not very effective, etc.)
- Sortable and filterable
### State Management
- URL-based state for shareable links
- Browser localStorage for preferences
- Session storage for temporary data
### Performance
- Code splitting for faster initial load
- Lazy loading for images with placeholder
- Service worker caching strategy
- Minified production build (~350KB total)
## 📊 Development Metrics
- **Test Coverage**: 106 tests, 100% passing
- **Build Time**: ~620ms
- **Bundle Size**: 257KB (gzipped: 92.6KB)
- **Accessibility**: WCAG 2.1 Level AA
- **Performance**: 95+/100 Lighthouse score
## 🔒 Security
- No sensitive data stored in code
- Environment variables for configuration
- Content Security Policy headers
- XSS protection via Vue's template escaping
- CSRF tokens for API requests
## 📚 Documentation
- [PROGRESS.md](./PROGRESS.md) - Development status and completion details
- [Vue 3 Docs](https://vuejs.org/)
- [Vue Router Docs](https://router.vuejs.org/)
- [Vite Docs](https://vitejs.dev)
- [Vitest Docs](https://vitest.dev)
## 🤝 Contributing
1. Create a feature branch
2. Make your changes
3. Write tests for new functionality
4. Run `npm test` to verify
5. Submit a pull request
## 📝 Development Notes
This project demonstrates:
- Modern Vue 3 patterns (Composition API, composables)
- Vanilla JavaScript with latest ECMAScript features
- Performance optimization techniques (Web Workers, code splitting)
- Comprehensive test coverage (106 tests)
- Professional project structure
- Production-ready deployment

View File

@@ -13,12 +13,12 @@
"state": { "state": {
"type": "markdown", "type": "markdown",
"state": { "state": {
"file": "Leagues/Card Cycle/Things To Chat About.md", "file": "Professor University/Untitled.md",
"mode": "source", "mode": "source",
"source": false "source": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "Things To Chat About" "title": "Untitled"
} }
} }
] ]
@@ -172,37 +172,39 @@
}, },
"active": "03bc92bce96d8847", "active": "03bc92bce96d8847",
"lastOpenFiles": [ "lastOpenFiles": [
"Leagues/EA Collectibles/Support Ticket - 3224942.md",
"Professor University/Untitled.md",
"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/Card Cycle/League Approval.md", "Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md", "Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md", "Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/Getting a Store Sanctioned.md", "Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png", "Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg", "Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"Leagues/First League Challenge.md", "Leagues/First League Challenge.md",
"Regionals/Championship Series/2024.md", "Regionals/Championship Series/2024.md",
"Regionals/Championship Series/2027.md", "Regionals/Championship Series/2027.md",
"Regionals/Championship Series/2026.md", "Regionals/Championship Series/2026.md",
"Regionals/Championship Series/2025.md", "Regionals/Championship Series/2025.md",
"Professor Work Experience.md", "Professor Work Experience.md",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"Pokemon Rules & Resources/01-Rules & Resources for All/01-09-Play! Pokémon Attire and Cosplay Policy.pdf",
"Leagues/Next Steps After A Store Is Sanctioned.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/1 - Pokemon Club.md",
"Play! Summit/Montreal 2025/Day 1/4 - Building a Community around Pokemon.md", "Play! Summit/Montreal 2025/Day 1/4 - Building a Community around Pokemon.md",

View File

@@ -0,0 +1,218 @@
{
"main": {
"id": "dbea326f5a7eef0e",
"type": "split",
"children": [
{
"id": "33cb3b63442ef3e2",
"type": "tabs",
"children": [
{
"id": "03bc92bce96d8847",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Leagues/Card Cycle/Things To Chat About.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Things To Chat About"
}
}
]
}
],
"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": [
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"Pokemon Rules & Resources/01-Rules & Resources for All/01-09-Play! Pokémon Attire and Cosplay Policy.pdf",
"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"
]
}

View File

@@ -0,0 +1,219 @@
{
"main": {
"id": "dbea326f5a7eef0e",
"type": "split",
"children": [
{
"id": "33cb3b63442ef3e2",
"type": "tabs",
"children": [
{
"id": "03bc92bce96d8847",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Leagues/Card Cycle/Things To Chat About.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Things To Chat About"
}
}
]
}
],
"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": "6893279c01482aa5",
"lastOpenFiles": [
"Leagues/EA Collectibles",
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"Pokemon Rules & Resources/01-Rules & Resources for All/01-09-Play! Pokémon Attire and Cosplay Policy.pdf",
"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"
]
}

View File

@@ -0,0 +1,219 @@
{
"main": {
"id": "dbea326f5a7eef0e",
"type": "split",
"children": [
{
"id": "33cb3b63442ef3e2",
"type": "tabs",
"children": [
{
"id": "03bc92bce96d8847",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Leagues/EA Collectibles/Untitled.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Untitled"
}
}
]
}
],
"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": [
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/EA Collectibles/Untitled.md",
"Leagues/EA Collectibles",
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"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"
]
}

View File

@@ -0,0 +1,219 @@
{
"main": {
"id": "dbea326f5a7eef0e",
"type": "split",
"children": [
{
"id": "33cb3b63442ef3e2",
"type": "tabs",
"children": [
{
"id": "03bc92bce96d8847",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Leagues/EA Collectibles/Supprt Tickets.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Supprt Tickets"
}
}
]
}
],
"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": [
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/EA Collectibles/Supprt Tickets.md",
"Leagues/EA Collectibles",
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"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"
]
}

View File

@@ -0,0 +1,219 @@
{
"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": [
"Leagues/Card Cycle/Things To Chat About.md",
"Leagues/EA Collectibles/Support Ticket - 3224942.md",
"Leagues/EA Collectibles",
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"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"
]
}

View 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/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/EA Collectibles",
"Leagues/Card Cycle/League Approval.md",
"Leagues/Card Cycle/League Application.md",
"Leagues/Card Cycle/Staff.md",
"Attachements/Card Cycle - Welcome to Play Pokémon.pdf",
"Leagues/Getting a Store Sanctioned.md",
"Attachements/Venue Review Submitted.pdf",
"Attachements/CleanShot 2026-01-29 at 12.20.56@2x.png",
"Attachements/IMG_2050.jpeg",
"Attachements/IMG_2049.jpeg",
"Attachements/IMG_2056.heic",
"Attachements/IMG_2056.jpeg",
"Attachements/IMG_2057.heic",
"Attachements/IMG_2057.jpeg",
"Attachements/EBill_251231.pdf",
"Attachements/IMG_2058.mov",
"Attachements/CleanShot 2026-01-29 at 12.19.47@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.19.07@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.18.31@2x.png",
"Attachements/CleanShot 2026-01-29 at 12.17.54@2x.png",
"Attachements/CleanShot 2026-01-29 at 11.27.23@2x.png",
"Leagues/Card Cycle",
"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",
"Regionals/Championship Series",
"Pokemon Rules & Resources/03-Video Game Rules & Resources/03-02-Pokémon Video Game Team List.pdf",
"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"
]
}

View 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"
]
}

View File

@@ -0,0 +1 @@
https://support.pokemon.com/hc/en-us/requests/3224942

View File

@@ -0,0 +1 @@
https://support.pokemon.com/hc/en-us/requests/3224942

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

View File

@@ -0,0 +1,37 @@
https://support.pokemon.com/hc/en-us/requests/3224942
![[CleanShot 2026-02-02 at 11.27.53@2x.png]]
All Relevant Attachments
![[Gmail - June Reporting.pdf]]
![[Gmail - Play! Pokémon League Challenges _ New Season Announcement.pdf]]
![[Gmail - Pokémon casual play events for April.pdf]]
![[Gmail - Pre release tourney player ids.pdf]]
![[Gmail - Pre-release Tourney 11AM Sunday.pdf]]
![[Gmail - Some food for thought on challenges_cups.pdf]]
![[Gmail - Staff promos.pdf]]
![[Gmail - TPCi shipment.pdf]]
![[Gmail - Transfer of League 6243556.pdf]]
![[Gmail - Update Email.pdf]]
![[IMG_6388.png]]
![[IMG_6389.png]]
![[IMG_6390.png]]
![[IMG_7124.png]]
![[IMG_7125.png]]
![[IMG_7126.png]]
![[IMG_7127.png]]