feat(admin): DB CRUD panel + /api/admin endpoints (#38)
CI / build + unit + e2e (pull_request) Successful in 3m47s

- ADMIN button in the mission-control header opens a table picker + row grid with add/edit/delete (components/admin-panel.tsx), deliberately unauthenticated by explicit request
- /api/admin/tables and /api/admin/tables/{name}/rows driven off a name→table registry built from lib/db/schema.ts (lib/db/admin.ts) — no per-table code; 404 unknown table, 400 malformed payload, 409 constraint violation
- OpenAPI spec updated for all four admin operations + Admin* schemas
- unit: lib/db/admin.test.ts (registry, CRUD round-trips, composite PKs, error mapping); e2e: read-only admin.spec.ts
This commit is contained in:
2026-09-29 21:48:15 +00:00
parent be11aac356
commit 0d2370850f
11 changed files with 1098 additions and 1 deletions
+292
View File
@@ -557,6 +557,240 @@ export const openApiSpec = {
},
},
},
'/api/admin/tables': {
get: {
summary: 'Admin: list DB tables',
description:
"The game's SQLite tables exposed by the admin CRUD (#38) — name, columns (with primary-key positions) and row count. No auth by design.",
operationId: 'adminListTables',
responses: {
200: {
description: 'Table list with column metadata and row counts',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
},
},
},
},
},
},
'/api/admin/tables/{name}/rows': {
get: {
summary: 'Admin: read rows of a table',
description: 'Rows of one admin table as objects keyed by column name, capped at 200 per page.',
operationId: 'adminReadRows',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables (e.g. 'saves')",
},
{
name: 'limit',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 1, maximum: 200 },
description: 'Rows per page (default 50)',
},
{
name: 'offset',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 0 },
description: 'Row offset for paging (default 0)',
},
],
responses: {
200: {
description: 'Rows plus the total row count of the table',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRows' },
},
},
},
400: {
description: 'limit/offset out of range',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
post: {
summary: 'Admin: insert a row',
description: "Insert one row. Body is an object of column → value (numbers for INTEGER/REAL columns, strings or null for TEXT); autoincrement ids may be omitted.",
operationId: 'adminInsertRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowValues' },
},
},
},
responses: {
200: {
description: 'The inserted row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body, unknown column, missing required column or wrong value type',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Constraint violation (UNIQUE or foreign key)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
patch: {
summary: 'Admin: update a row',
description: "Update one row by primary key. Body is { key, values } — key holds the table's PK columns (composite keys supported), values the new column → value pairs.",
operationId: 'adminUpdateRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowUpdate' },
},
},
},
responses: {
200: {
description: 'The updated row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body, bad key or unknown column',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name, or no row with that key",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Constraint violation (UNIQUE or foreign key)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
delete: {
summary: 'Admin: delete a row',
description: "Delete one row by primary key. Body is { key } with the table's PK columns.",
operationId: 'adminDeleteRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowKey' },
},
},
},
responses: {
200: {
description: 'The deleted row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body or bad key',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name, or no row with that key",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
},
components: {
schemas: {
@@ -790,6 +1024,64 @@ export const openApiSpec = {
},
],
},
AdminColumn: {
type: 'object',
properties: {
name: { type: 'string', description: "SQL column name, e.g. 'save_id'" },
type: { type: 'string', enum: ['INTEGER', 'REAL', 'TEXT'], description: 'Declared SQLite type' },
pk: { type: 'integer', description: 'Position within the primary key (0 = not a PK); composite keys come out ordered' },
},
required: ['name', 'type', 'pk'],
},
AdminTable: {
type: 'object',
properties: {
name: { type: 'string', description: "SQL table name, e.g. 'saves'" },
columns: { type: 'array', items: { $ref: '#/components/schemas/AdminColumn' } },
count: { type: 'integer', description: 'Current row count' },
},
required: ['name', 'columns', 'count'],
},
AdminRows: {
type: 'object',
properties: {
rows: {
type: 'array',
description: 'Rows as objects keyed by column name (null for NULL cells)',
items: { type: 'object' },
},
total: { type: 'integer', description: 'Total row count of the table (before paging)' },
},
required: ['rows', 'total'],
},
AdminRowValues: {
type: 'object',
additionalProperties: true,
description: "Column → value pairs for an insert — numbers for INTEGER/REAL columns, strings or null for TEXT; autoincrement ids may be omitted",
},
AdminRowKey: {
type: 'object',
properties: {
key: {
type: 'object',
additionalProperties: { type: 'integer' },
description: "The table's primary-key columns (composite keys supported)",
},
},
required: ['key'],
},
AdminRowUpdate: {
allOf: [
{ $ref: '#/components/schemas/AdminRowKey' },
{
type: 'object',
properties: {
values: { $ref: '#/components/schemas/AdminRowValues' },
},
required: ['values'],
},
],
},
Error: {
type: 'object',
properties: {