v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s

This commit is contained in:
2026-09-24 09:24:31 +00:00
parent 034d7a8d94
commit b89d351587
25 changed files with 2089 additions and 6 deletions
+74
View File
@@ -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 };
}
+36
View File
@@ -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();
+29
View File
@@ -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.
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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: {