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
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// 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`; }
|
||||
}
|
||||
Reference in New Issue
Block a user