v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

533
server/src/admin-crud.js Normal file
View file

@ -0,0 +1,533 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN CRUD ENGINE
Reads a descriptor from admin-schema.js and does the SQL. No
entity names appear in this file.
Writes run inside tx() so a parent, its side table, and every
child collection either all land or none do. Children are
replaced wholesale rather than diffed: the client sends the list
it wants to exist, the engine deletes and reinserts in array
order, and sort_order becomes the index. That makes drag-to-
reorder free and removes a whole class of "which row is this"
bugs, at the cost of churning autoincrement ids — which is fine
precisely because nothing references them.
A column that isn't present in the payload at all is left out of
the statement entirely, so the table's DEFAULT applies on insert
and the existing value survives on update. A column present but
empty ("" or null) is an explicit clear and writes NULL. The
difference matters: sending NULL for every unmentioned column is
what turns a missing form field into a NOT NULL constraint
failure instead of a default.
═══════════════════════════════════════════════════════════════ */
import { tx } from "./db.js";
export class HttpError extends Error {
constructor(status, message, fields) {
super(message);
this.status = status;
this.fields = fields;
}
}
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/* Not a value the caller can ever send, so it can mean "leave this
column out of the statement" without colliding with real data. */
const OMIT = Symbol("omit");
/* ── Coercion ──────────────────────────────────────────────────
SQLite STRICT tables reject a type mismatch at the wall, but
the error it throws is unreadable. Everything is converted and
checked here so failures come back as named fields.
───────────────────────────────────────────────────────────── */
function coerceValue(column, raw, errors, prefix = "") {
const key = `${prefix}${column.name}`;
// Absent from the payload. Fall back to the descriptor default
// if it declares one, otherwise let the column's own DEFAULT do
// the work — which needs the column omitted, not nulled.
if (raw === undefined) {
if (column.default !== undefined) return column.default;
if (column.required) errors[key] = "Required.";
return OMIT;
}
// Before the blank check: an unchecked box legitimately arrives
// as false, "", or null, and all of those mean 0, not NULL.
if (column.type === "bool") {
return raw === true || raw === 1 || raw === "1" || raw === "true" ? 1 : 0;
}
if (raw === null || raw === "") {
if (column.required) {
errors[key] = "Required.";
return null;
}
// The table refuses NULL but declares a default: clearing the
// field means "use the default", not "write NULL".
if (column.notNullable) return OMIT;
return null;
}
switch (column.type) {
case "int": {
const n = Number(raw);
if (!Number.isInteger(n)) errors[key] = "Must be a whole number.";
return Number.isInteger(n) ? n : null;
}
case "real": {
const n = Number(raw);
if (!Number.isFinite(n)) errors[key] = "Must be a number.";
return Number.isFinite(n) ? n : null;
}
case "enum": {
const value = String(raw);
if (!column.values.includes(value)) {
errors[key] = `Must be one of: ${column.values.join(", ")}.`;
return null;
}
return value;
}
case "date": {
const value = String(raw).trim();
if (!ISO_DATE.test(value)) errors[key] = "Use YYYY-MM-DD.";
return ISO_DATE.test(value) ? value : null;
}
default: {
const value = String(raw).trim();
return value === "" ? null : value;
}
}
}
function coerceRow(columns, data, { prefix = "" } = {}) {
const errors = {};
const values = {};
for (const column of columns) {
const value = coerceValue(column, data?.[column.name], errors, prefix);
if (value !== OMIT) values[column.name] = value;
}
return { values, errors };
}
const applies = (gate, row) => !gate || row[gate.column] === gate.value;
/* ── Read ────────────────────────────────────────────────────── */
export function listRows(db, entity, query = {}) {
const { columns, filters, search, order } = entity.list;
const where = [];
const params = [];
for (const name of filters) {
const value = query[name];
if (value === undefined || value === "" || value === "all") continue;
where.push(`${name} = ?`);
params.push(value);
}
if (query.q) {
const term = `%${String(query.q).slice(0, 100)}%`;
where.push(`(${search.map((c) => `${c} LIKE ?`).join(" OR ")})`);
params.push(...search.map(() => term));
}
const limit = Math.min(Number(query.limit) || 200, 500);
const rows = db
.prepare(
`SELECT ${columns.join(", ")}
FROM ${entity.table}
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY ${order}
LIMIT ?`,
)
.all(...params, limit);
return { rows, total: rows.length };
}
export function readRow(db, entity, id) {
const row = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (!row) throw new HttpError(404, "Not found.");
for (const ext of entity.extensions ?? []) {
row[ext.key] =
db
.prepare(`SELECT * FROM ${ext.table} WHERE ${ext.idColumn} = ?`)
.get(id) ?? null;
}
for (const child of entity.children ?? []) {
row[child.key] = readChildren(db, child, id);
}
return row;
}
function readChildren(db, child, ownerId) {
const where = [`${child.owner.column} = ?`];
const params = [ownerId];
if (child.owner.kindColumn) {
where.push(`${child.owner.kindColumn} = ?`);
params.push(child.owner.kindValue);
}
const rows = db
.prepare(
`SELECT * FROM ${child.table}
WHERE ${where.join(" AND ")}
ORDER BY ${child.order}`,
)
.all(...params);
for (const child2 of child.children ?? []) {
for (const row of rows) {
row[child2.key] = db
.prepare(
`SELECT * FROM ${child2.table}
WHERE ${child2.owner.column} = ?
ORDER BY ${child2.order}`,
)
.all(row.id);
}
}
return rows;
}
/* ── Write ───────────────────────────────────────────────────── */
export function createRow(db, entity, payload) {
const id = String(payload?.[entity.idColumn] ?? "").trim().toLowerCase();
if (entity.idKind === "slug" && !SLUG.test(id)) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Lowercase letters, numbers and hyphens only.",
});
}
const exists = db
.prepare(`SELECT 1 FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (exists) {
throw new HttpError(422, "Validation failed", {
[entity.idColumn]: "Already taken.",
});
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [entity.idColumn, ...Object.keys(values)];
wrapDbErrors(() =>
tx(db, () => {
db.prepare(
`INSERT INTO ${entity.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
).run(id, ...Object.values(values));
writeExtensions(db, entity, id, payload, values);
writeChildren(db, entity, id, payload, values);
}),
);
return readRow(db, entity, id);
}
export function updateRow(db, entity, id, payload) {
const current = db
.prepare(`SELECT * FROM ${entity.table} WHERE ${entity.idColumn} = ?`)
.get(id);
if (!current) throw new HttpError(404, "Not found.");
// Optimistic concurrency. The client echoes back the updated_at
// it loaded; anything else means someone saved in between.
if (entity.concurrency) {
const seen = payload?.[entity.concurrency];
if (!seen) {
throw new HttpError(400, `Missing ${entity.concurrency}.`);
}
if (seen !== current[entity.concurrency]) {
throw new HttpError(
409,
"Someone else saved this while you were editing. Reload to see their version.",
);
}
}
const { values, errors } = coerceRow(entity.columns, payload);
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
// An unsent column keeps its stored value, so the gates below
// have to read the merged row, not just what came in.
const merged = { ...current, ...values };
wrapDbErrors(() =>
tx(db, () => {
const sets = Object.keys(values).map((name) => `${name} = ?`);
if (sets.length) {
db.prepare(
`UPDATE ${entity.table} SET ${sets.join(", ")}
WHERE ${entity.idColumn} = ?`,
).run(...Object.values(values), id);
}
writeExtensions(db, entity, id, payload, merged);
writeChildren(db, entity, id, payload, merged);
}),
);
return readRow(db, entity, id);
}
export function deleteRow(db, entity, id) {
const result = wrapDbErrors(() =>
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
);
if (result.changes === 0) throw new HttpError(404, "Not found.");
}
/* ── Write helpers ───────────────────────────────────────────── */
function writeExtensions(db, entity, id, payload, parentValues) {
for (const ext of entity.extensions ?? []) {
if (!applies(ext.when, parentValues)) {
// The gate closed — the kind changed away from this side
// table, so its row (and anything cascading off it) goes.
db.prepare(`DELETE FROM ${ext.table} WHERE ${ext.idColumn} = ?`).run(id);
continue;
}
const { values, errors } = coerceRow(ext.columns, payload[ext.key] ?? {}, {
prefix: `${ext.key}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
if (ext.touch) values.updated_at = new Date().toISOString().replace("T", " ").slice(0, 19);
const names = [ext.idColumn, ...Object.keys(values)];
const sets = Object.keys(values).map((n) => `${n} = excluded.${n}`);
// Upsert rather than delete-and-insert: deleting a regions row
// would cascade its region_areas away underneath us. With every
// optional column omitted there is nothing to set, so the
// conflict clause has to degrade to DO NOTHING or the SQL is
// syntactically invalid.
db.prepare(
`INSERT INTO ${ext.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})
ON CONFLICT(${ext.idColumn}) ${
sets.length ? `DO UPDATE SET ${sets.join(", ")}` : "DO NOTHING"
}`,
).run(id, ...Object.values(values));
}
}
function isBlankChildRow(child, raw) {
if (!raw || typeof raw !== "object") return true;
for (const column of child.columns) {
const value = raw[column.name];
if (value === undefined || value === null || value === "") continue;
// An unchecked box is the default state of a new row, not input.
if (
column.type === "bool" &&
(value === false || value === 0 || value === "0" || value === "false")
) {
continue;
}
return false;
}
for (const child2 of child.children ?? []) {
const nested = raw[child2.key];
if (Array.isArray(nested) && nested.some((item) => !isBlankChildRow(child2, item))) {
return false;
}
}
return true;
}
function keyedDbErrors(prefix, fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(String(err.message ?? ""));
if (notNull) {
throw new HttpError(422, "Validation failed", {
[`${prefix}${notNull[1]}`]: "Required.",
});
}
throw err;
}
}
function writeChildren(db, entity, id, payload, parentValues) {
for (const child of entity.children ?? []) {
if (!applies(child.when, parentValues)) {
deleteChildren(db, child, id);
continue;
}
if (payload[child.key] === undefined) continue; // not sent, not touched
deleteChildren(db, child, id);
const incoming = Array.isArray(payload[child.key]) ? payload[child.key] : [];
// index is the row's place in what the client sent, so a
// validation error still points at the row the user is looking
// at. position counts only the rows that survive, so dropping a
// blank in the middle doesn't leave a gap in sort_order.
const rows = incoming
.map((raw, index) => ({ raw, index }))
.filter(({ raw }) => child.allowBlank || !isBlankChildRow(child, raw));
rows.forEach(({ raw, index }, position) => {
const { values, errors } = coerceRow(child.columns, raw, {
prefix: `${child.key}.${index}.`,
});
if (Object.keys(errors).length) {
throw new HttpError(422, "Validation failed", errors);
}
const names = [child.owner.column, ...Object.keys(values)];
const params = [id, ...Object.values(values)];
if (child.owner.kindColumn) {
names.push(child.owner.kindColumn);
params.push(child.owner.kindValue);
}
for (const [column, from] of Object.entries(child.owner.inherit ?? {})) {
if (names.includes(column)) continue;
names.push(column);
params.push(parentValues[from] ?? id);
}
if (child.order === "sort_order" && !names.includes("sort_order")) {
names.push("sort_order");
params.push(position);
}
const result = keyedDbErrors(`${child.key}.${index}.`, () =>
db
.prepare(
`INSERT INTO ${child.table} (${names.join(", ")})
VALUES (${names.map(() => "?").join(", ")})`,
)
.run(...params),
);
for (const child2 of child.children ?? []) {
const incomingNested = Array.isArray(raw[child2.key]) ? raw[child2.key] : [];
const nested = incomingNested
.map((rawItem, i) => ({ rawItem, i }))
.filter(({ rawItem }) => child2.allowBlank || !isBlankChildRow(child2, rawItem));
nested.forEach(({ rawItem, i }, nestedPosition) => {
const item = coerceRow(child2.columns, rawItem, {
prefix: `${child.key}.${index}.${child2.key}.${i}.`,
});
if (Object.keys(item.errors).length) {
throw new HttpError(422, "Validation failed", item.errors);
}
const itemNames = [child2.owner.column, ...Object.keys(item.values)];
const itemParams = [result.lastInsertRowid, ...Object.values(item.values)];
if (!itemNames.includes("sort_order")) {
itemNames.push("sort_order");
itemParams.push(nestedPosition);
}
db.prepare(
`INSERT INTO ${child2.table} (${itemNames.join(", ")})
VALUES (${itemNames.map(() => "?").join(", ")})`,
).run(...itemParams);
});
}
});
}
}
function deleteChildren(db, child, ownerId) {
if (child.owner.kindColumn) {
db.prepare(
`DELETE FROM ${child.table}
WHERE ${child.owner.column} = ? AND ${child.owner.kindColumn} = ?`,
).run(ownerId, child.owner.kindValue);
} else {
db.prepare(`DELETE FROM ${child.table} WHERE ${child.owner.column} = ?`).run(
ownerId,
);
}
}
/* SQLite's constraint messages are accurate and unreadable. Turn
the ones that users actually cause into something actionable,
and name the column wherever the message carries it — a missing
field is the client's problem to fix, not a 500.
The descriptor and the schema agreeing (see admin-schema-sync.js)
should stop most of these arriving. This is the backstop for the
cases it can't see: partial indexes, triggers, CHECK constraints. */
function wrapDbErrors(fn) {
try {
return fn();
} catch (err) {
if (err instanceof HttpError) throw err;
const message = String(err.message ?? "");
const notNull = /NOT NULL constraint failed: \w+\.(\w+)/.exec(message);
if (notNull) {
throw new HttpError(422, "Validation failed", {
[notNull[1]]: "Required.",
});
}
const unique = /UNIQUE constraint failed: (.+)/.exec(message);
if (unique) {
const columns = unique[1]
.split(",")
.map((part) => part.trim().split(".")[1])
.filter(Boolean);
if (columns.length === 1) {
throw new HttpError(422, "Validation failed", {
[columns[0]]: "Already taken.",
});
}
throw new HttpError(422, "That combination already exists.");
}
if (message.includes("FOREIGN KEY")) {
throw new HttpError(
422,
"Something references a row that doesn't exist, or is still referenced elsewhere.",
);
}
const check = /CHECK constraint failed: (\w+)/.exec(message);
if (check) {
throw new HttpError(422, `A value was rejected by the "${check[1]}" rule.`);
}
throw err;
}
}