import { persist } from '@/lib/db/client'; import type { GameDb } from '@/lib/db/client'; import { requireAdmin } from '@/lib/auth'; // v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any // user input is touched). SQL safety: table names are quoted only after validation against // sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE // ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string. const DEFAULT_LIMIT = 500; const MAX_LIMIT = 1000; function quoteIdent(name: string): string { return '"' + name.replace(/"/g, '""') + '"'; } // The app's own tables (sqlite_% internals excluded) — the only names ever accepted. function tableNames(inst: GameDb): Set { const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); return new Set(res[0].values.map((r) => String(r[0]))); } // Column names of a validated table (name already checked against sqlite_master). function columnsOf(inst: GameDb, name: string): string[] { const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`); return res[0].values.map((r) => String(r[1])); } // Parameterized execute — the only way values reach SQL in this file. function run(inst: GameDb, sql: string, values: unknown[]): void { inst.raw.run(sql, values as never[]); } async function readBody(req: Request): Promise | Response> { let body: unknown; try { const text = await req.text(); body = text ? JSON.parse(text) : undefined; } catch { return Response.json({ error: 'invalid JSON body' }, { status: 400 }); } if (typeof body !== 'object' || body === null || Array.isArray(body)) { return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); } const input = body as Record; for (const key of Object.keys(input)) { if (!ALLOWED_BODY_FIELDS.has(key)) { return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); } } return input; } // Per-method top-level body fields (strict, like the /api/test/* hooks). const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']); function valuesObject(input: Record): Record | Response { const values = input.values; if (typeof values !== 'object' || values === null || Array.isArray(values)) { return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 }); } return values as Record; } // Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit; // anything else is a server error. function sqlError(err: unknown): Response { const msg = err instanceof Error ? err.message : String(err); if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) { return Response.json({ error: `database constraint: ${msg}` }, { status: 409 }); } console.error('admin rows route failed', err); return Response.json({ error: 'database error' }, { status: 500 }); } // GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default // 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable // like any other. Unknown table → 404; bad limit → 400. export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) { const guard = await requireAdmin(req); if (!guard.ok) return guard.response; const { name } = await ctx.params; if (!tableNames(guard.inst).has(name)) { return Response.json({ error: 'unknown table' }, { status: 404 }); } let limit = DEFAULT_LIMIT; const limitParam = new URL(req.url).searchParams.get('limit'); if (limitParam !== null) { limit = Number(limitParam); if (!Number.isInteger(limit) || limit < 1) { return Response.json({ error: 'limit must be a positive integer' }, { status: 400 }); } limit = Math.min(limit, MAX_LIMIT); } const cols = columnsOf(guard.inst, name); const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`); try { stmt.bind([limit]); const rows: Array> = []; while (stmt.step()) rows.push(stmt.getAsObject() as Record); return Response.json({ columns: ['__rowid', ...cols], rows }); } catch (err) { return sqlError(err); } finally { stmt.free(); } } // PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the // given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty // values → 400. export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) { const guard = await requireAdmin(req); if (!guard.ok) return guard.response; const input = await readBody(req); if (input instanceof Response) return input; const rowid = input.rowid; if (!Number.isInteger(rowid)) { return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 }); } const values = await valuesObject(input); if (values instanceof Response) return values; const { name } = await ctx.params; if (!tableNames(guard.inst).has(name)) { return Response.json({ error: 'unknown table' }, { status: 404 }); } const cols = columnsOf(guard.inst, name); for (const col of Object.keys(values)) { if (!cols.includes(col)) { return Response.json({ error: `unknown column "${col}"` }, { status: 400 }); } } if (Object.keys(values).length === 0) { return Response.json({ error: 'values must contain at least one column' }, { status: 400 }); } const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', '); try { run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [ ...Object.values(values), rowid, ]); await persist(guard.inst); return Response.json({ ok: true }); } catch (err) { return sqlError(err); } } // POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with // exactly the given columns (others take their defaults or NULL). Returns the new rowid. export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) { const guard = await requireAdmin(req); if (!guard.ok) return guard.response; const input = await readBody(req); if (input instanceof Response) return input; const values = await valuesObject(input); if (values instanceof Response) return values; if (Object.keys(values).length === 0) { return Response.json({ error: 'values must contain at least one column' }, { status: 400 }); } const { name } = await ctx.params; if (!tableNames(guard.inst).has(name)) { return Response.json({ error: 'unknown table' }, { status: 404 }); } const cols = columnsOf(guard.inst, name); for (const col of Object.keys(values)) { if (!cols.includes(col)) { return Response.json({ error: `unknown column "${col}"` }, { status: 400 }); } } const keys = Object.keys(values); try { run( guard.inst, `INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, Object.values(values), ); // Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets // last_insert_rowid to 0 on this connection. const res = guard.inst.raw.exec('SELECT last_insert_rowid()'); await persist(guard.inst); return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) }); } catch (err) { return sqlError(err); } } // DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404; // missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent). export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) { const guard = await requireAdmin(req); if (!guard.ok) return guard.response; const { name } = await ctx.params; if (!tableNames(guard.inst).has(name)) { return Response.json({ error: 'unknown table' }, { status: 404 }); } const rowidParam = new URL(req.url).searchParams.get('rowid'); const rowid = Number(rowidParam); if (rowidParam === null || !Number.isInteger(rowid)) { return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 }); } try { run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]); await persist(guard.inst); return Response.json({ ok: true }); } catch (err) { return sqlError(err); } }