Files
bkvargyasandClaude Fable 5.1 5ade592384 Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper
tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process
Node server with SQLite via node:sqlite and a WebSocket live board, organizer
desk, QR landing page that follows the tournament phase, Dockerfile and
compose with cloudflared, and documentation for deploying and running a day.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-03 20:31:46 +00:00

45 lines
2.0 KiB
JavaScript

// Organizer authentication. Two options, both simple:
// 1. ADMIN_PASSWORD — a login form sets a signed cookie for 12 hours.
// 2. Cloudflare Access — if TRUST_CF_ACCESS=true, a request that carries the
// Cf-Access-Authenticated-User-Email header (set by Cloudflare after the user passes
// the Access policy on /admin) is treated as an organizer. Only enable this when the
// app is reachable exclusively through the tunnel, otherwise the header can be forged.
import { timingSafeEqual, randomBytes } from 'node:crypto';
import { parseCookies, sign, verify } from './http.js';
const COOKIE = 'courtside_admin';
const TTL_MS = 12 * 3600 * 1000;
export class Auth {
constructor({ password, secret, trustCfAccess = false }) {
this.password = password || null;
this.secret = secret || randomBytes(32).toString('hex'); // random secret = sessions reset on restart; fine for a fallback
this.trustCfAccess = trustCfAccess;
if (!this.password && !this.trustCfAccess) console.warn('[auth] ADMIN_PASSWORD is not set and TRUST_CF_ACCESS is off: the organizer desk is unreachable.');
}
/** Returns the organizer identity or null. */
identify(req) {
if (this.trustCfAccess && req.headers['cf-access-authenticated-user-email']) return String(req.headers['cf-access-authenticated-user-email']);
const c = parseCookies(req)[COOKIE];
const v = verify(c, this.secret);
if (!v) return null;
const [exp, who] = v.split('|');
if (Number(exp) < Date.now()) return null;
return who || 'organizer';
}
checkPassword(candidate) {
if (!this.password) return false;
const a = Buffer.from(String(candidate)), b = Buffer.from(this.password);
return a.length === b.length && timingSafeEqual(a, b);
}
cookie(who = 'organizer', secure = false) {
const v = sign(`${Date.now() + TTL_MS}|${who}`, this.secret);
return `${COOKIE}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TTL_MS / 1000}${secure ? '; Secure' : ''}`;
}
clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; Max-Age=0`; }
}