v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
CI / build + unit + e2e (pull_request) Failing after 13m35s
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { DELETE, GET, POST, PUT } from './route';
|
||||
|
||||
// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables),
|
||||
// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input).
|
||||
|
||||
let dir: string;
|
||||
let adminSession = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// adminSession is minted in beforeAll, so the header helpers read it at call time.
|
||||
const authHeaders = () => ({ 'X-Flight-Session': adminSession });
|
||||
|
||||
// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly.
|
||||
const ctx = (name: string) => ({ params: Promise.resolve({ name }) });
|
||||
|
||||
const getRows = (name: string, query = '') =>
|
||||
GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name));
|
||||
const putRow = (name: string, body: unknown) =>
|
||||
PUT(
|
||||
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
ctx(name),
|
||||
);
|
||||
const postRow = (name: string, body: unknown) =>
|
||||
POST(
|
||||
new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
ctx(name),
|
||||
);
|
||||
const deleteRow = (name: string, rowid: number | null) =>
|
||||
DELETE(
|
||||
new Request(
|
||||
`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`,
|
||||
{ method: 'DELETE', headers: authHeaders() },
|
||||
),
|
||||
ctx(name),
|
||||
);
|
||||
|
||||
describe('admin rows CRUD', () => {
|
||||
beforeAll(async () => {
|
||||
adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
});
|
||||
|
||||
it('GET returns columns + rows with __rowid for the PK-less account table', async () => {
|
||||
const res = await getRows('account');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { columns: string[]; rows: Array<Record<string, unknown>> };
|
||||
expect(body.columns[0]).toBe('__rowid');
|
||||
for (const col of ['cash', 'reputation']) expect(body.columns).toContain(col);
|
||||
expect(body.rows).toHaveLength(1); // the single seeded account row
|
||||
expect(typeof body.rows[0].__rowid).toBe('number');
|
||||
expect(typeof body.rows[0].cash).toBe('number');
|
||||
});
|
||||
|
||||
it('PUT updates a cell and the change survives a re-fetch', async () => {
|
||||
const before = await (await getRows('account')).json();
|
||||
const originalCash = (before as { rows: Array<Record<string, unknown>> }).rows[0].cash;
|
||||
|
||||
const res = await putRow('account', { rowid: 1, values: { cash: 424_242 } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
|
||||
// Re-fetch through the same route — the persisted value is back.
|
||||
const body = await (await getRows('account')).json();
|
||||
expect((body as { rows: Array<Record<string, unknown>> }).rows[0].cash).toBe(424_242);
|
||||
|
||||
// Restore the original cash so later tests see a sane world.
|
||||
const restore = await putRow('account', { rowid: 1, values: { cash: originalCash } });
|
||||
expect(restore.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT rejects unknown columns and empty values with 400', async () => {
|
||||
const badCol = await putRow('account', { rowid: 1, values: { no_such_column: 1 } });
|
||||
expect(badCol.status).toBe(400);
|
||||
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||
|
||||
const empty = await putRow('account', { rowid: 1, values: {} });
|
||||
expect(empty.status).toBe(400);
|
||||
|
||||
const noRowid = await putRow('account', { values: { cash: 1 } });
|
||||
expect(noRowid.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST inserts a row and DELETE removes it again (users table)', async () => {
|
||||
const created = await postRow('users', {
|
||||
values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() },
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const body = (await created.json()) as { ok: boolean; rowid: number };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(Number.isInteger(body.rowid)).toBe(true);
|
||||
|
||||
// Visible via GET…
|
||||
let list = await (await getRows('users')).json();
|
||||
const rows = (list as { rows: Array<Record<string, unknown>> }).rows;
|
||||
expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true);
|
||||
|
||||
// …and gone after DELETE.
|
||||
const del = await deleteRow('users', body.rowid);
|
||||
expect(del.status).toBe(200);
|
||||
list = await (await getRows('users')).json();
|
||||
expect((list as { rows: Array<Record<string, unknown>> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false);
|
||||
|
||||
// DELETE is idempotent.
|
||||
const again = await deleteRow('users', body.rowid);
|
||||
expect(again.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST rejects unknown columns and empty values with 400', async () => {
|
||||
const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } });
|
||||
expect(badCol.status).toBe(400);
|
||||
expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i);
|
||||
|
||||
const empty = await postRow('users', { values: {} });
|
||||
expect(empty.status).toBe(400);
|
||||
});
|
||||
|
||||
it('DELETE requires an integer rowid query parameter', async () => {
|
||||
const missing = await deleteRow('users', null);
|
||||
expect(missing.status).toBe(400);
|
||||
const bad = await DELETE(
|
||||
new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }),
|
||||
ctx('users'),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown table on every verb', async () => {
|
||||
const g = await getRows('no_such_table');
|
||||
expect(g.status).toBe(404);
|
||||
const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } });
|
||||
expect(p.status).toBe(404);
|
||||
const i = await postRow('no_such_table', { values: { x: 1 } });
|
||||
expect(i.status).toBe(404);
|
||||
const d = await deleteRow('no_such_table', 1);
|
||||
expect(d.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects a non-integer limit with 400 and caps at 1000', async () => {
|
||||
const bad = await getRows('users', '?limit=abc');
|
||||
expect(bad.status).toBe(400);
|
||||
const capped = await getRows('users', '?limit=999999');
|
||||
expect(capped.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => {
|
||||
const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users'));
|
||||
expect(noHeader.status).toBe(401);
|
||||
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`,
|
||||
[Date.now()],
|
||||
);
|
||||
const nonAdminSession = await createSession(inst, 'user-rows-nonadmin');
|
||||
const forbidden = await GET(
|
||||
new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }),
|
||||
ctx('users'),
|
||||
);
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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<string> {
|
||||
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<Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>): Record<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
// 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 (account, 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<Record<string, unknown>> = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { createSession } from '@/lib/auth';
|
||||
import { GET } from './route';
|
||||
|
||||
// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403,
|
||||
// admin passes and sees every user table (sqlite_% excluded).
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-'));
|
||||
process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.FLIGHT_DB_PATH;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (sessionId?: string) =>
|
||||
GET(
|
||||
new Request('http://localhost/api/admin/tables', {
|
||||
headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
async function makeUser(username: string, admin: number): Promise<string> {
|
||||
const inst = await getGameDb();
|
||||
const id = `user-${username}`;
|
||||
inst.raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`,
|
||||
[id, username, admin, Date.now(), id],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('GET /api/admin/tables', () => {
|
||||
it('returns 401 without a session header', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for an expired session', async () => {
|
||||
const inst = await getGameDb();
|
||||
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||
'expired-admin-session',
|
||||
'admin-kamilkrzywda',
|
||||
Date.now() - 1000,
|
||||
]);
|
||||
const res = await get('expired-admin-session');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for a valid non-admin session', async () => {
|
||||
const userId = await makeUser('tables-nonadmin', 0);
|
||||
const sessionId = await createSession(await getGameDb(), userId);
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(403);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('lists every user table for an admin, excluding sqlite_% internals', async () => {
|
||||
const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda');
|
||||
const res = await get(sessionId);
|
||||
expect(res.status).toBe(200);
|
||||
const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name);
|
||||
|
||||
for (const expected of ['account', 'packages', 'planes', 'sessions', 'sim_state', 'users']) {
|
||||
expect(names, `missing ${expected}`).toContain(expected);
|
||||
}
|
||||
expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false);
|
||||
// Name-ordered.
|
||||
expect([...names].sort()).toEqual(names);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name
|
||||
// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker.
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const guard = await requireAdmin(req);
|
||||
if (!guard.ok) return guard.response;
|
||||
|
||||
const res = guard.inst.raw.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
);
|
||||
return Response.json(res[0].values.map((r) => ({ name: String(r[0]) })));
|
||||
}
|
||||
Reference in New Issue
Block a user