import { Request, Response, Express } from 'express'; import { createClient, SupabaseClient } from '@supabase/supabase-js'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { Settings } from './types'; import { logDebug, logInfo } from './server_log'; const BCRYPT_ROUNDS = 10; const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days const USERNAME_MIN = 2; const PASSWORD_MIN = 6; const EMAIL_MAX = 254; const SYSTEM_ACCOUNT_ID = 1; /** 1 USD = 4 RC (IAP packs) */ export const RC_PER_USD = 4; /** Fixed packs: $5, $20, $50 */ export const RC_PACKS: Record = { usd_5: { usd: 5, rc: 5 * RC_PER_USD }, usd_20: { usd: 20, rc: 20 * RC_PER_USD }, usd_50: { usd: 50, rc: 50 * RC_PER_USD }, }; function isIapDummyMode(settings: Settings): boolean { return settings.iap_dummy_mode === true; } export interface AuthUserPublic { id: string; username: string; email: string | null; cc: number; rc: number; created_at: string; last_logged_at: string | null; } function authConfigured(settings: Settings): boolean { return Boolean( settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim() && settings.auth_jwt_secret?.trim() ); } /** Supabase URL + service role only (e.g. match history without full auth). */ export function supabaseConfigured(settings: Settings): boolean { return Boolean(settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim()); } export function createSupabase(settings: Settings): SupabaseClient { return createClient(settings.supabase_url!.trim(), settings.supabase_service_role_key!.trim(), { auth: { persistSession: false, autoRefreshToken: false }, }); } function signToken(userId: string, secret: string): string { const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC; const payload = JSON.stringify({ sub: userId, exp }); const bodyB64 = Buffer.from(payload, 'utf8').toString('base64url'); const sig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url'); return `${bodyB64}.${sig}`; } export function verifyAuthToken(token: string, secret: string): { userId: string } | null { const parts = token.split('.'); if (parts.length !== 2) return null; const [bodyB64, sig] = parts; const expectedSig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url'); const a = Buffer.from(sig, 'utf8'); const b = Buffer.from(expectedSig, 'utf8'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; try { const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as { sub?: string; exp?: number; }; if (!payload.sub || typeof payload.exp !== 'number') return null; if (payload.exp < Math.floor(Date.now() / 1000)) return null; return { userId: payload.sub }; } catch { return null; } } function readBearer(req: Request): string | null { const h = req.headers.authorization; if (!h || typeof h !== 'string') return null; const m = /^Bearer\s+(.+)$/i.exec(h.trim()); return m ? m[1]!.trim() : null; } function readClientIp(req: Request): string | null { const forwarded = req.headers['x-forwarded-for']; const headerValue = Array.isArray(forwarded) ? forwarded[0] : forwarded; if (typeof headerValue === 'string') { const first = headerValue .split(',') .map((part) => part.trim()) .find((part) => part.length > 0); if (first) return first; } if (typeof req.ip === 'string' && req.ip.trim().length > 0) return req.ip.trim(); if (typeof req.socket?.remoteAddress === 'string' && req.socket.remoteAddress.trim().length > 0) { return req.socket.remoteAddress.trim(); } return null; } function coerceDbUserId(id: unknown): number | null { if (id == null) return null; if (typeof id === 'bigint') return Number(id); if (typeof id === 'number' && Number.isFinite(id)) return id; if (typeof id === 'string') { const n = parseInt(id, 10); return Number.isNaN(n) ? null : n; } return null; } function normalizeEmail(raw: string): string { return raw.trim().toLowerCase(); } function isValidEmail(email: string): boolean { if (email.length < 3 || email.length > EMAIL_MAX) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function rowToAuthUserPublic(row: Record): AuthUserPublic { return { id: String(row.id), username: typeof row.username === 'string' ? row.username : String(row.username ?? ''), email: row.email != null && row.email !== '' ? String(row.email) : null, cc: Number(row.cc ?? 0), rc: Number(row.rc ?? 0), created_at: typeof row.created_at === 'string' ? row.created_at : String(row.created_at ?? ''), last_logged_at: row.last_logged_at != null && row.last_logged_at !== '' ? String(row.last_logged_at) : null, }; } function isMissingPurchaseRpcError(err: { code?: string; message?: string } | null): boolean { if (!err) return false; if (err.code === 'PGRST202') return true; const m = err.message ?? ''; return m.includes('purchase_rc') || m.includes('Could not find the function'); } function summarizePostgrestErr(err: { code?: string; message?: string; details?: string; hint?: string }): object { return { code: err.code ?? null, message: err.message ?? null, details: err.details ?? null, hint: err.hint ?? null, }; } /** Integer-ish RC for comparisons after reads (DB may still be `real` until bigint migration). */ function roundedStoredRc(value: unknown): number { return Math.round(Number(value ?? 0)); } export interface MatchmakingUser { userId: number; username: string; } /** * Validates Bearer session token and loads the user from Supabase (id + username). * Used by GET / and GET /cancel matchmaking. */ export async function authenticateMatchmakingRequest( req: Request, settings: Settings ): Promise<{ ok: true; user: MatchmakingUser } | { ok: false; status: number; message: string }> { if (!authConfigured(settings)) { return { ok: false, status: 503, message: 'Auth not configured' }; } const token = readBearer(req); if (!token) { return { ok: false, status: 401, message: 'Missing Authorization: Bearer ' }; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { return { ok: false, status: 401, message: 'Invalid or expired token' }; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('users') .select('id, username') .eq('id', v.userId) .maybeSingle(); if (error || !data || typeof data.username !== 'string' || data.username.length < 2) { return { ok: false, status: 404, message: 'User not found' }; } const userId = coerceDbUserId(data.id); if (userId == null) { return { ok: false, status: 500, message: 'Invalid user id' }; } return { ok: true, user: { userId, username: data.username } }; } export function registerAuthRoutes(app: Express, settings: Settings): void { app.post('/auth/register', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; const emailRaw = typeof req.body?.email === 'string' ? req.body.email : ''; const email = normalizeEmail(emailRaw); if (username.length < USERNAME_MIN) { res.status(400).json({ ok: false, error: `Username must be at least ${USERNAME_MIN} characters` }); return; } if (!isValidEmail(email)) { res.status(400).json({ ok: false, error: 'A valid email address is required' }); return; } if (password.length < PASSWORD_MIN) { res.status(400).json({ ok: false, error: `Password must be at least ${PASSWORD_MIN} characters` }); return; } const supabase = createSupabase(settings); const [{ data: existingUser }, { data: existingEmail }] = await Promise.all([ supabase.from('users').select('id').eq('username', username).maybeSingle(), supabase.from('users').select('id').eq('email', email).maybeSingle(), ]); if (existingUser) { res.status(409).json({ ok: false, error: 'Username already taken' }); return; } if (existingEmail) { res.status(409).json({ ok: false, error: 'Email already registered' }); return; } const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); const clientIp = readClientIp(req); const { data: row, error } = await supabase .from('users') .insert({ username, email, password: hash, cc: 0, rc: 0, ip_address: clientIp, }) .select('id, username, email, cc, rc, created_at, last_logged_at') .single(); if (error || !row) { if (error?.code === '23505') { const msg = error.message ?? ''; if (msg.includes('email')) { res.status(409).json({ ok: false, error: 'Email already registered' }); return; } if (msg.includes('username')) { res.status(409).json({ ok: false, error: 'Username already taken' }); return; } } res.status(500).json({ ok: false, error: error?.message ?? 'Registration failed' }); return; } const user = rowToAuthUserPublic(row as Record); const token = signToken(user.id, settings.auth_jwt_secret!.trim()); res.status(201).json({ ok: true, token, user }); }); app.post('/auth/login', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; if (username.length < USERNAME_MIN || !password) { res.status(400).json({ ok: false, error: 'Invalid username or password' }); return; } const supabase = createSupabase(settings); const { data: row, error } = await supabase .from('users') .select('id, username, email, password, cc, rc, created_at') .eq('username', username) .maybeSingle(); if (error || !row) { res.status(401).json({ ok: false, error: 'Invalid username or password' }); return; } const match = await bcrypt.compare(password, row.password); if (!match) { res.status(401).json({ ok: false, error: 'Invalid username or password' }); return; } const nowIso = new Date().toISOString(); const clientIp = readClientIp(req); await supabase.from('users').update({ last_logged_at: nowIso, ip_address: clientIp }).eq('id', row.id); const user: AuthUserPublic = { ...rowToAuthUserPublic(row as Record), last_logged_at: nowIso, }; const token = signToken(user.id, settings.auth_jwt_secret!.trim()); res.json({ ok: true, token, user }); }); /** Current user from DB (requires login token). Same behavior as GET /auth/user. */ const handleGetCurrentUser = async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const token = readBearer(req); if (!token) { res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer ' }); return; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { res.status(401).json({ ok: false, error: 'Invalid or expired token' }); return; } const supabase = createSupabase(settings); const { data, error } = await supabase .from('users') .select('id, username, email, cc, rc, created_at, last_logged_at') .eq('id', v.userId) .maybeSingle(); if (error || !data) { res.status(404).json({ ok: false, error: 'User not found' }); return; } res.json({ ok: true, user: rowToAuthUserPublic(data as Record) }); }; app.get('/auth/me', handleGetCurrentUser); app.get('/auth/user', handleGetCurrentUser); /** Public catalog for Unity UI (no auth). */ app.get('/auth/rc-packs', (_req: Request, res: Response) => { const packs = Object.entries(RC_PACKS).map(([id, p]) => ({ id, usd: p.usd, rc: p.rc, })); res.json({ ok: true, rc_per_usd: RC_PER_USD, packs }); }); /** Client reports observed ping for a match. User id comes from login token. */ app.post('/auth/ping-report', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } const token = readBearer(req); if (!token) { res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer ' }); return; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { res.status(401).json({ ok: false, error: 'Invalid or expired token' }); return; } const body = req.body as Record; const matchId = coerceDbUserId(body?.match_id); const ping = typeof body?.ping === 'number' && Number.isFinite(body.ping) ? Math.trunc(body.ping) : typeof body?.ping === 'string' && /^-?\d+$/.test(body.ping.trim()) ? parseInt(body.ping.trim(), 10) : null; if (matchId == null || matchId < 1) { res.status(400).json({ ok: false, error: 'match_id must be a positive integer' }); return; } if (ping == null || ping < 0) { res.status(400).json({ ok: false, error: 'ping must be a non-negative integer' }); return; } const userId = coerceDbUserId(v.userId); if (userId == null || userId < 1) { res.status(400).json({ ok: false, error: 'Invalid token user id' }); return; } const supabase = createSupabase(settings); const ipAddress = readClientIp(req); const { data: inserted, error } = await supabase .from('ping_reports') .insert({ user_id: userId, match_id: matchId, ping, ip_address: ipAddress, }) .select('id, user_id, match_id, ping, ip_address, created_at') .single(); if (error || !inserted) { if (error?.code === '23503') { res.status(404).json({ ok: false, error: 'Referenced user or match not found' }); return; } res.status(500).json({ ok: false, error: error?.message ?? 'Failed to report ping' }); return; } res.status(201).json({ ok: true, report: inserted }); }); /** * Dummy IAP: adds RC for a named pack when `iap_dummy_mode` is true in settings. * Later: same route can require a verified store receipt when dummy mode is off. */ app.post('/auth/purchase-rc', async (req: Request, res: Response) => { if (!authConfigured(settings)) { res.status(503).json({ ok: false, error: 'Auth is not configured on the server' }); return; } if (!isIapDummyMode(settings)) { res.status(403).json({ ok: false, error: 'Dummy purchases are disabled. Configure server-side purchase verification.', }); return; } const token = readBearer(req); if (!token) { res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer ' }); return; } const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim()); if (!v) { res.status(401).json({ ok: false, error: 'Invalid or expired token' }); return; } const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : ''; const pack = RC_PACKS[packId]; if (!pack) { const valid = Object.keys(RC_PACKS).join(', '); res.status(400).json({ ok: false, error: `Unknown pack "${packId}". Valid: ${valid}`, }); return; } const supabase = createSupabase(settings); const buyerId = coerceDbUserId(v.userId); if (buyerId == null || buyerId < 1) { res.status(400).json({ ok: false, error: 'Invalid token user id' }); return; } const amount = Math.trunc(pack.rc); const logLevel = settings.log_level ?? 0; const selfPurchase = buyerId === SYSTEM_ACCOUNT_ID; logInfo('[purchase-rc] start', { buyer_id: buyerId, pack_id: packId, amount, self_purchase: selfPurchase, }); const { data: rpcData, error: rpcErr } = await supabase.rpc('purchase_rc', { p_buyer_id: buyerId, p_amount: amount, }); logDebug(logLevel, '[purchase-rc] rpc raw', { has_error: Boolean(rpcErr), error: rpcErr ? summarizePostgrestErr(rpcErr) : null, data_type: rpcData == null ? 'null' : typeof rpcData, data_is_array: Array.isArray(rpcData), }); if (!rpcErr && rpcData != null && typeof rpcData === 'object' && !Array.isArray(rpcData)) { const user = rowToAuthUserPublic(rpcData as Record); logInfo('[purchase-rc] rpc success', { buyer_id: buyerId, path: 'purchase_rc', buyer_rc_after: user.rc, }); res.json({ ok: true, pack: { id: packId, usd: pack.usd, rc_added: amount }, user, }); return; } if (rpcErr && !isMissingPurchaseRpcError(rpcErr)) { logInfo('[purchase-rc] rpc failed (not fallback)', summarizePostgrestErr(rpcErr)); const msg = rpcErr.message ?? ''; if (msg.includes('insufficient_supply')) { res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' }); return; } if (msg.includes('buyer_not_found')) { res.status(404).json({ ok: false, error: 'User not found' }); return; } if (msg.includes('invalid_buyer') || msg.includes('invalid_amount')) { res.status(400).json({ ok: false, error: 'Invalid purchase' }); return; } res.status(500).json({ ok: false, error: msg }); return; } if (rpcErr && isMissingPurchaseRpcError(rpcErr)) { logInfo('[purchase-rc] rpc not available — fallback', { ...summarizePostgrestErr(rpcErr), hint: 'Apply schemas/rpc_purchase_rc.sql in Supabase SQL editor', }); } else if (!rpcErr && (rpcData == null || typeof rpcData !== 'object')) { logInfo('[purchase-rc] rpc returned empty or non-object — fallback', { rpc_data_null: rpcData == null, typeof_data: typeof rpcData, }); } /** Fallback when `public.purchase_rc` is not installed — apply `schemas/rpc_purchase_rc.sql` in Supabase. */ if (buyerId === SYSTEM_ACCOUNT_ID) { logInfo('[purchase-rc] fallback self-purchase branch', { buyer_id: buyerId, amount }); const { data: sysRow, error: oneErr } = await supabase .from('users') .select('id, username, email, cc, rc, created_at, last_logged_at') .eq('id', SYSTEM_ACCOUNT_ID) .single(); if (oneErr || !sysRow) { logInfo('[purchase-rc] fallback self: load system user failed', summarizePostgrestErr(oneErr ?? {})); res.status(404).json({ ok: false, error: 'System account or user not found' }); return; } const sourceRc = Math.round(Number((sysRow as { rc: unknown }).rc ?? 0)); logDebug(logLevel, '[purchase-rc] fallback self: balances', { system_rc: sourceRc, }); if (sourceRc < amount) { logInfo('[purchase-rc] reject insufficient supply (fallback self)', { source_rc: sourceRc, amount, }); res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' }); return; } const { error: txErr } = await supabase.from('transactions').insert({ from: SYSTEM_ACCOUNT_ID, to: SYSTEM_ACCOUNT_ID, amount, remarks: 'purchase', }); if (txErr) { logInfo('[purchase-rc] fallback self: transaction insert failed', summarizePostgrestErr(txErr)); res.status(500).json({ ok: false, error: `Purchase logged failed: ${txErr.message}` }); return; } logInfo('[purchase-rc] fallback self success (audit only, rc unchanged)', { buyer_id: buyerId, }); res.json({ ok: true, pack: { id: packId, usd: pack.usd, rc_added: amount }, user: rowToAuthUserPublic(sysRow as Record), }); return; } const { data: rows, error: selErr } = await supabase .from('users') .select('id, rc') .in('id', [buyerId, SYSTEM_ACCOUNT_ID]); logDebug(logLevel, '[purchase-rc] fallback two-party select', { error: selErr ? summarizePostgrestErr(selErr) : null, row_count: rows?.length ?? 0, ids: rows?.map((r) => ({ id: r.id, rc: r.rc })), }); if (selErr || !rows || rows.length < 2) { logInfo('[purchase-rc] reject not enough user rows', { row_count: rows?.length ?? 0, need: 2, select_error: selErr ? summarizePostgrestErr(selErr) : null, }); res.status(404).json({ ok: false, error: 'User not found' }); return; } const supply = rows.find((r) => coerceDbUserId(r.id) === SYSTEM_ACCOUNT_ID); const target = rows.find((r) => coerceDbUserId(r.id) === buyerId); if (!supply || !target) { logInfo('[purchase-rc] reject missing supply or target row after select', { has_supply: Boolean(supply), has_target: Boolean(target), }); res.status(404).json({ ok: false, error: 'System account or user not found' }); return; } const sourceRc = Math.round(Number(supply.rc ?? 0)); const targetRc = Math.round(Number(target.rc ?? 0)); if (sourceRc < amount) { logInfo('[purchase-rc] reject insufficient supply (fallback)', { source_rc: sourceRc, amount, }); res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' }); return; } const newSourceRc = sourceRc - amount; const { data: sourceAfter, error: sourceUpErr } = await supabase .from('users') .update({ rc: newSourceRc }) .eq('id', SYSTEM_ACCOUNT_ID) .select('id, rc') .single(); logDebug(logLevel, '[purchase-rc] fallback debit system', { error: sourceUpErr ? summarizePostgrestErr(sourceUpErr) : null, intended_rc: newSourceRc, returned_row: sourceAfter, }); if (sourceUpErr || !sourceAfter) { logInfo('[purchase-rc] debit system account failed', { error: sourceUpErr ? summarizePostgrestErr(sourceUpErr) : { message: 'no row returned' }, }); res.status(500).json({ ok: false, error: sourceUpErr?.message ?? 'Could not debit supply account (id 1). Check RLS/policies or that this row exists.', }); return; } const rcAfterDebit = roundedStoredRc((sourceAfter as { rc: unknown }).rc); if (rcAfterDebit !== newSourceRc) { logInfo( '[purchase-rc] stored rc did not match debit — likely PostgreSQL `real` (float32) cannot represent this delta at the current balance. Apply schemas/alter_users_rc_bigint.sql.', { intended_rc: newSourceRc, stored_rc_rounded: rcAfterDebit, supply_rc_before: sourceRc, amount, } ); res.status(500).json({ ok: false, error: 'RC storage type is too coarse for this balance (float32 loses small changes around large balances). Run schemas/alter_users_rc_bigint.sql in Supabase SQL, then retry.', }); return; } const { data: updated, error: upErr } = await supabase .from('users') .update({ rc: targetRc + amount }) .eq('id', buyerId) .select('id, username, email, cc, rc, created_at, last_logged_at') .single(); logDebug(logLevel, '[purchase-rc] fallback credit buyer', { error: upErr ? summarizePostgrestErr(upErr) : null, buyer_id: buyerId, intended_rc: targetRc + amount, returned_row_id: updated?.id, }); if (upErr || !updated) { await supabase.from('users').update({ rc: sourceRc }).eq('id', SYSTEM_ACCOUNT_ID); logInfo('[purchase-rc] credit buyer failed — rolled back system rc', { buyer_id: buyerId, error: upErr ? summarizePostgrestErr(upErr) : { message: 'no row returned' }, }); res.status(500).json({ ok: false, error: upErr?.message ?? 'Failed to update balance' }); return; } const intendedBuyerRc = targetRc + amount; const buyerRcStored = roundedStoredRc(updated.rc); if (buyerRcStored !== intendedBuyerRc) { await supabase.from('users').update({ rc: sourceRc }).eq('id', SYSTEM_ACCOUNT_ID); await supabase.from('users').update({ rc: targetRc }).eq('id', buyerId); logInfo( '[purchase-rc] credit verification failed — rolled back both users; check users.rc column type (real vs bigint)', { buyer_id: buyerId, intended_buyer_rc: intendedBuyerRc, stored_buyer_rc: buyerRcStored, amount, } ); res.status(500).json({ ok: false, error: 'RC balance update did not persist correctly (precision/column type). Run schemas/alter_users_rc_bigint.sql in Supabase, then retry.', }); return; } const { error: txErr } = await supabase.from('transactions').insert({ from: SYSTEM_ACCOUNT_ID, to: buyerId, amount, remarks: 'purchase', }); if (txErr) { logInfo('[purchase-rc] fallback: transaction insert failed after balance updates', summarizePostgrestErr(txErr)); res.status(500).json({ ok: false, error: `Purchase logged failed: ${txErr.message}` }); return; } const user = rowToAuthUserPublic(updated as Record); logInfo('[purchase-rc] fallback success', { buyer_id: buyerId, system_rc_after: sourceAfter.rc, buyer_rc_after: user.rc, }); res.json({ ok: true, pack: { id: packId, usd: pack.usd, rc_added: amount }, user, }); }); }