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:
+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