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:
+74
@@ -0,0 +1,74 @@
|
||||
import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
|
||||
// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque
|
||||
// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it
|
||||
// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is
|
||||
// a single-origin dev tool surface, not a public auth system.
|
||||
|
||||
export const SESSION_HEADER = 'x-flight-session';
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation
|
||||
|
||||
// Fixed digest compared against when the username is unknown, so every failed login runs one
|
||||
// full scrypt and the timing shape stays uniform (no user-existence oracle).
|
||||
export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt,
|
||||
// 32-byte hash). Malformed digests never match.
|
||||
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||
const [saltHex, hashHex] = storedHash.split('$');
|
||||
if (!saltHex || !hashHex || hashHex.length !== 64) return false;
|
||||
const computed = scryptSync(password, saltHex, 32).toString('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mint a session row for the user and lazily prune expired sessions. Persists so a restart
|
||||
// doesn't silently log everyone out mid-session.
|
||||
export async function createSession(inst: GameDb, userId: string): Promise<string> {
|
||||
const id = randomUUID();
|
||||
inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]);
|
||||
inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [
|
||||
id,
|
||||
userId,
|
||||
Date.now() + SESSION_TTL_MS,
|
||||
]);
|
||||
await persist(inst);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Resolve the X-Flight-Session header to a live (non-expired) user, or null.
|
||||
export async function currentUser(req: Request): Promise<AuthUser | null> {
|
||||
const sessionId = req.headers.get(SESSION_HEADER);
|
||||
if (!sessionId) return null;
|
||||
const inst = await getGameDb();
|
||||
const res = inst.raw.exec(
|
||||
'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?',
|
||||
[sessionId, Date.now()],
|
||||
);
|
||||
const row = res[0]?.values?.[0];
|
||||
if (!row) return null;
|
||||
return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 };
|
||||
}
|
||||
|
||||
export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response };
|
||||
|
||||
// Guard for every /api/admin/* route — runs BEFORE any user input is touched.
|
||||
// No/expired session → 401, valid non-admin session → 403.
|
||||
export async function requireAdmin(req: Request): Promise<AdminGuard> {
|
||||
const user = await currentUser(req);
|
||||
if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) };
|
||||
if (!user.admin) {
|
||||
return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) };
|
||||
}
|
||||
return { ok: true, inst: await getGameDb(), user };
|
||||
}
|
||||
@@ -222,6 +222,42 @@ describe('openGameDb', () => {
|
||||
expect(cols.has('dest_icao')).toBe(true);
|
||||
});
|
||||
|
||||
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
|
||||
const ADMIN_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
|
||||
|
||||
it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
|
||||
const file = path.join(dir, 'admin-seed.db');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
rows = second.raw.exec('SELECT COUNT(*) FROM users');
|
||||
expect(Number(rows[0].values[0][0])).toBe(1);
|
||||
});
|
||||
|
||||
it('seeds the admin into a legacy DB that predates the users table', async () => {
|
||||
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(LEGACY_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||
const file = path.join(dir, 'legacy-admin.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
|
||||
// The game world is untouched by the seed.
|
||||
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
|
||||
});
|
||||
|
||||
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
||||
const file = path.join(dir, 'pre-v12-reopen.db');
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
@@ -62,8 +62,36 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the
|
||||
// operator's head — never in code, tests or comments; login verifies against this string.
|
||||
const ADMIN_SEED_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0';
|
||||
|
||||
// v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so
|
||||
// pre-existing production DBs gain exactly one admin row and never a duplicate.
|
||||
function seedAdminUser(raw: RawSqlJsDatabase): void {
|
||||
raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
|
||||
[ADMIN_SEED_HASH, Date.now()],
|
||||
);
|
||||
}
|
||||
|
||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||
// already have everything from DDL above and this is a no-op for them.
|
||||
@@ -161,6 +189,7 @@ async function open(file: string): Promise<GameDb> {
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
migrateColumns(raw);
|
||||
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
|
||||
|
||||
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
||||
// without this it would keep the dead contracts table forever.
|
||||
|
||||
@@ -84,7 +84,27 @@ export const simState = sqliteTable('sim_state', {
|
||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||
});
|
||||
|
||||
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
username: text('username').notNull().unique(),
|
||||
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
export type PackageRow = typeof packages.$inferSelect;
|
||||
export type AccountRow = typeof account.$inferSelect;
|
||||
export type SimStateRow = typeof simState.$inferSelect;
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
+55
-1
@@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
|
||||
it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/admin/tables', // v0.71.0 — admin panel
|
||||
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
|
||||
'/api/airports',
|
||||
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
|
||||
'/api/login', // v0.71.0 — admin panel auth
|
||||
'/api/logout', // v0.71.0
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/me', // v0.71.0
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
@@ -242,4 +247,53 @@ describe('OpenAPI spec', () => {
|
||||
expect(sell.required).toEqual(['planeId']);
|
||||
expect(sell.properties.planeId.type).toBe('string');
|
||||
});
|
||||
|
||||
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
|
||||
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
|
||||
|
||||
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
|
||||
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
|
||||
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
|
||||
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
|
||||
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
|
||||
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
|
||||
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
|
||||
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
|
||||
}
|
||||
|
||||
// Success shapes.
|
||||
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
|
||||
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
|
||||
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
|
||||
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
|
||||
expect(tables.type).toBe('array');
|
||||
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
|
||||
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
|
||||
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
|
||||
|
||||
// Request bodies + path parameter.
|
||||
const bodyRef = (p: string, m: string) =>
|
||||
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
|
||||
?.schema.$ref;
|
||||
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
|
||||
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
|
||||
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
|
||||
for (const m of ['get', 'put', 'post', 'delete'] as const) {
|
||||
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
|
||||
}
|
||||
|
||||
// Request schema shapes.
|
||||
const login = openApiSpec.components.schemas.LoginRequest;
|
||||
expect(login.required).toEqual(['username', 'password']);
|
||||
expect(login.properties.username.type).toBe('string');
|
||||
expect(login.properties.password.type).toBe('string');
|
||||
const sessionUser = openApiSpec.components.schemas.SessionUser;
|
||||
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
|
||||
expect(sessionUser.properties.admin.type).toBe('boolean');
|
||||
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
|
||||
expect(updateRow.required).toEqual(['rowid', 'values']);
|
||||
expect(updateRow.properties.rowid.type).toBe('integer');
|
||||
});
|
||||
});
|
||||
|
||||
+325
-2
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
||||
info: {
|
||||
title: 'Flight Ops API',
|
||||
description:
|
||||
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||
'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.',
|
||||
// Sourced from package.json so it stays in sync with every version bump.
|
||||
version: pkg.version,
|
||||
},
|
||||
@@ -342,7 +342,244 @@ export const openApiSpec = {
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag.
|
||||
'/api/login': {
|
||||
post: {
|
||||
summary: 'Log in',
|
||||
description:
|
||||
'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.',
|
||||
operationId: 'login',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/LoginRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Authenticated — new session id + the user',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Unknown username or wrong password',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/logout': {
|
||||
post: {
|
||||
summary: 'Log out',
|
||||
description:
|
||||
'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.',
|
||||
operationId: 'logout',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Session deleted (or was already gone)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing X-Flight-Session header',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/me': {
|
||||
get: {
|
||||
summary: 'Current user',
|
||||
description:
|
||||
'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.',
|
||||
operationId: 'getMe',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The authenticated user',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/admin/tables': {
|
||||
get: {
|
||||
summary: 'List database tables (admin)',
|
||||
description:
|
||||
'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.',
|
||||
operationId: 'listAdminTables',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The table names',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/admin/tables/{name}/rows': {
|
||||
get: {
|
||||
summary: 'Read table rows (admin)',
|
||||
description:
|
||||
'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.',
|
||||
operationId: 'listAdminRows',
|
||||
parameters: [
|
||||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Column headers + rows (each row carries its __rowid)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'limit is not a positive integer',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
summary: 'Update a row (admin)',
|
||||
description:
|
||||
'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.',
|
||||
operationId: 'updateAdminRow',
|
||||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/UpdateRowRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Updated',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body, non-integer rowid, unknown column, or empty values',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
summary: 'Insert a row (admin)',
|
||||
description:
|
||||
'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.',
|
||||
operationId: 'insertAdminRow',
|
||||
parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/InsertRowRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Inserted — the new rowid',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid body, unknown column, or empty values',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Database constraint violation (NOT NULL / UNIQUE)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete a row (admin)',
|
||||
description:
|
||||
'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.',
|
||||
operationId: 'deleteAdminRow',
|
||||
parameters: [
|
||||
{ name: 'name', in: 'path', required: true, schema: { type: 'string' } },
|
||||
{ name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Deleted (or was already gone)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } },
|
||||
},
|
||||
400: {
|
||||
description: 'Missing or non-integer rowid query parameter',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
401: {
|
||||
description: 'Missing, unknown or expired session',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
403: {
|
||||
description: 'Valid session but the user is not an admin',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown table name',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
@@ -496,6 +733,92 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['planeId'],
|
||||
},
|
||||
// v0.71.0 — admin panel auth + DB tooling schemas.
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', description: 'Account username (non-empty).' },
|
||||
password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' },
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
},
|
||||
SessionUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' },
|
||||
},
|
||||
required: ['username', 'admin'],
|
||||
},
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' },
|
||||
user: { $ref: '#/components/schemas/SessionUser' },
|
||||
},
|
||||
required: ['sessionId', 'user'],
|
||||
},
|
||||
OkResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { const: true },
|
||||
},
|
||||
required: ['ok'],
|
||||
},
|
||||
AdminTable: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
AdminRowsResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
columns: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).',
|
||||
},
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
description: 'Up to `limit` rows, each keyed by column name including __rowid.',
|
||||
},
|
||||
},
|
||||
required: ['columns', 'rows'],
|
||||
},
|
||||
UpdateRowRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rowid: { type: 'integer', description: 'The __rowid of the row to update.' },
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.',
|
||||
},
|
||||
},
|
||||
required: ['rowid', 'values'],
|
||||
},
|
||||
InsertRowRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
values: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: 'Exactly the columns to set; others take their defaults or NULL.',
|
||||
},
|
||||
},
|
||||
required: ['values'],
|
||||
},
|
||||
InsertRowResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { const: true },
|
||||
rowid: { type: 'integer', description: 'The __rowid of the inserted row.' },
|
||||
},
|
||||
required: ['ok', 'rowid'],
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
Reference in New Issue
Block a user