import { randomUUID } from 'node:crypto'; import { db } from '../db/index.js'; type KeyGame = 't5' | 't6'; export interface ServerKey { id: string; game: KeyGame; label: string; updated_at: string; assigned_servers: string[]; } function validGame(game: unknown): asserts game is KeyGame { if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 for the server key'); } function normalizedKey(key: unknown): string { if ( typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim()) ) { throw new Error('Enter a server key without whitespace (maximum 512 characters)'); } return key.trim(); } function normalizedLabel(label: unknown): string { if (typeof label !== 'string' || label.trim().length > 160 || /[\r\n]/.test(label)) { throw new Error('Enter a key label with at most 160 characters'); } return label.trim(); } export function listServerKeys(): ServerKey[] { const rows = db() .prepare('SELECT id,game,label,updated_at FROM server_keys ORDER BY game,label,id') .all() as Omit[]; const assignments = db() .prepare( `SELECT DISTINCT k.id AS key_id,l.server_id FROM server_keys k JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key JOIN server_key_leases l ON l.key_id=occupied.id ORDER BY l.server_id` ) .all() as { key_id: string; server_id: string }[]; const byKey = new Map(); for (const lease of assignments) { const servers = byKey.get(lease.key_id) ?? []; servers.push(lease.server_id); byKey.set(lease.key_id, servers); } return rows.map((row) => ({ ...row, assigned_servers: byKey.get(row.id) ?? [] })); } export function getServerKey(serverId: string): string | undefined { return ( db() .prepare( `SELECT k.pluto_key FROM server_key_leases l JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1` ) .get(serverId) as { pluto_key: string } | undefined )?.pluto_key; } /** Saving a credential twice cannot increase available capacity. */ export function saveServerKey(game: unknown, key: unknown, label?: unknown): string { validGame(game); const credential = normalizedKey(key); const keyLabel = label === undefined ? undefined : normalizedLabel(label); return db() .transaction(() => { const existing = db() .prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?') .get(game, credential) as { id: string } | undefined; if (existing) { if (keyLabel !== undefined) db() .prepare( "UPDATE server_keys SET label=?,updated_at=datetime('now') WHERE id=?" ) .run(keyLabel, existing.id); return existing.id; } const id = randomUUID(); db() .prepare('INSERT INTO server_keys(id,game,label,pluto_key) VALUES(?,?,?,?)') .run(id, game, keyLabel ?? '', credential); return id; }) .immediate(); } // Compare the credential as well as its row: a mistakenly imported copy in // another game's pool must not permit simultaneous use of the same key. const availableKey = `NOT EXISTS (SELECT 1 FROM server_key_leases l JOIN server_keys occupied ON occupied.id=l.key_id WHERE occupied.pluto_key=k.pluto_key AND (l.server_id!=@server OR l.is_current=0))`; export function serverKeyPool( game: KeyGame, serverId?: string ): { total: number; available: number } { validGame(game); return db() .prepare( `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN ${availableKey} THEN 1 ELSE 0 END),0) AS available FROM server_keys k WHERE k.game=@game` ) .get({ game, server: serverId ?? '' }) as { total: number; available: number }; } export function availableServerKeyCount(game: KeyGame): number { return serverKeyPool(game).available; } /** Called within launch admission; the write lock prevents competing launches sharing a key. */ export function reserveServerKey(serverId: string, game: KeyGame, suppliedKey?: unknown): string { validGame(game); if (!serverId) throw new Error('Choose a server for the key reservation'); const credential = suppliedKey === undefined || suppliedKey === null || (typeof suppliedKey === 'string' && !suppliedKey.trim()) ? undefined : normalizedKey(suppliedKey); return db() .transaction(() => { const existing = db() .prepare( `SELECT k.game,k.pluto_key FROM server_key_leases l JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1` ) .get(serverId) as { game: KeyGame; pluto_key: string } | undefined; if (existing) { if (existing.game !== game) throw new Error('This server already has a key reserved for another game'); if (credential && credential !== existing.pluto_key) throw new Error( 'This server already has a reserved key; destroy it before changing its key' ); return existing.pluto_key; } const requestedId = credential ? saveServerKey(game, credential) : null; const key = db() .prepare( `SELECT k.id,k.pluto_key FROM server_keys k WHERE k.game=@game AND (@requestedId IS NULL OR k.id=@requestedId) AND ${availableKey} ORDER BY k.updated_at,k.id LIMIT 1` ) .get({ game, server: serverId, requestedId }) as { id: string; pluto_key: string } | undefined; if (!key) throw new Error( credential ? 'This server key is already reserved by another server' : `No available ${game.toUpperCase()} server keys. Add a key or destroy a server to release one.` ); db() .prepare('INSERT INTO server_key_leases(server_id,key_id) VALUES(?,?)') .run(serverId, key.id); return key.pluto_key; }) .immediate(); } /** Only after confirmed destruction or a creation cancelled before any host mutation. */ export function releaseServerKey(serverId: string): boolean { return ( db().prepare('DELETE FROM server_key_leases WHERE server_id=?').run(serverId).changes > 0 ); } export function removeServerKey(id: string): boolean { return db() .transaction(() => { if ( db() .prepare( `SELECT 1 FROM server_keys k JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key JOIN server_key_leases l ON l.key_id=occupied.id WHERE k.id=?` ) .get(id) ) { throw new Error( 'This key is reserved by a server. Destroy that server before removing its key.' ); } return db().prepare('DELETE FROM server_keys WHERE id=?').run(id).changes > 0; }) .immediate(); }