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

200
server/src/admin-cli.js Normal file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env node
/* ═══════════════════════════════════════════════════════════════
ADMIN ACCOUNT CLI
The only way an account comes into existence. Run it on the box,
against the live database:
cd /srv/ngu-api
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org
DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org
add takes --role=viewer for read-only, --name="Full Name".
Press enter at the password prompt and it generates one and
prints it once.
Changing or disabling a password also drops that person's live
sessions, so "disable" takes effect now rather than in 30 days.
═══════════════════════════════════════════════════════════════ */
import { createInterface } from "node:readline";
import { randomBytes } from "node:crypto";
import { openDatabase, migrate } from "./db.js";
import { hashPassword, destroyAllSessionsFor } from "./auth.js";
const MIN_PASSWORD = 12;
/* ── Prompt, with echo suppressed for secrets ────────────────── */
function ask(question, { hidden = false } = {}) {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
rl.muted = false;
rl._writeToOutput = function (text) {
if (!rl.muted) rl.output.write(text);
};
rl.question(question, (answer) => {
if (hidden) rl.output.write("\n");
rl.close();
resolve(answer);
});
rl.muted = hidden;
});
}
async function readPassword() {
const first = await ask("Password (enter to generate): ", { hidden: true });
if (first === "") {
const generated = randomBytes(12).toString("base64url");
console.log(`\nGenerated password: ${generated}`);
console.log("Copy it now — it isn't stored anywhere readable.\n");
return generated;
}
if (first.length < MIN_PASSWORD) {
fail(`Password must be at least ${MIN_PASSWORD} characters.`);
}
const second = await ask("Again: ", { hidden: true });
if (first !== second) fail("Passwords didn't match.");
return first;
}
function fail(message) {
console.error(`✗ ${message}`);
process.exit(1);
}
/* ── Commands ────────────────────────────────────────────────── */
function findUser(db, email) {
return db
.prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?")
.get(email);
}
async function add(db, email, flags) {
if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`);
const role = flags.role ?? "admin";
if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer.");
const password = await readPassword();
db.prepare(
`INSERT INTO admin_users (email, name, password_hash, role)
VALUES (?, ?, ?, ?)`,
).run(email, flags.name ?? null, hashPassword(password), role);
console.log(`✓ ${email} created as ${role}`);
}
async function passwd(db, email) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
const password = await readPassword();
db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run(
hashPassword(password),
user.id,
);
destroyAllSessionsFor(db, user.id);
console.log(`✓ password changed for ${email}, existing sessions ended`);
}
function setActive(db, email, active) {
const user = findUser(db, email);
if (!user) fail(`No account for ${email}.`);
db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run(
active ? 1 : 0,
user.id,
);
if (!active) destroyAllSessionsFor(db, user.id);
console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`);
}
function list(db) {
const rows = db
.prepare(
`SELECT u.email, u.name, u.role, u.is_active, u.last_login_at,
(SELECT COUNT(*) FROM sessions s
WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions
FROM admin_users u
ORDER BY u.email`,
)
.all();
if (rows.length === 0) {
console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org");
return;
}
for (const r of rows) {
const state = r.is_active ? r.role : "disabled";
const seen = r.last_login_at ?? "never";
console.log(
`${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`,
);
}
}
/* ── Entry ───────────────────────────────────────────────────── */
const [command, ...rest] = process.argv.slice(2);
const flags = {};
const positional = [];
for (const arg of rest) {
const match = /^--([^=]+)=(.*)$/.exec(arg);
if (match) flags[match[1]] = match[2];
else positional.push(arg);
}
const email = positional[0]?.trim().toLowerCase();
const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db");
migrate(db); // so a fresh database gets the tables before we use them
try {
switch (command) {
case "add":
if (!email) fail("Usage: admin-cli.js add email@example.com");
await add(db, email, flags);
break;
case "passwd":
if (!email) fail("Usage: admin-cli.js passwd email@example.com");
await passwd(db, email);
break;
case "disable":
setActive(db, email, false);
break;
case "enable":
setActive(db, email, true);
break;
case "list":
list(db);
break;
default:
console.log("Commands: add, passwd, disable, enable, list");
process.exit(command ? 1 : 0);
}
} finally {
db.close();
}

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;
}
}

View file

@ -0,0 +1,136 @@
/* ═══════════════════════════════════════════════════════════════
DESCRIPTOR / SCHEMA RECONCILIATION
The CRUD engine trusts admin-schema.js about which fields are
required. The database is the one that actually enforces it. When
those two disagree the gap shows up as a 500 on somebody's save,
which is how organizations.country was found.
Run this once at boot. For every table a descriptor touches it
compares the declared columns against PRAGMA table_info and:
- marks a descriptor column required when the table says
NOT NULL with no DEFAULT, so validation catches it as a named
field instead of SQLite catching it as a 500;
- reports a descriptor column the table doesn't have;
- reports a NOT NULL, no-DEFAULT column that no descriptor
column and no engine-supplied column covers — nothing can
ever set it, so every insert through the admin will fail.
The first is a silent repair. The other two are deployment bugs,
so with { strict: true } they stop the service starting rather
than waiting to surface one row at a time.
═══════════════════════════════════════════════════════════════ */
function tableMeta(db, table) {
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
if (!rows.length) return null;
const meta = new Map();
for (const row of rows) {
meta.set(row.name, {
// A NOT NULL column with no default and no engine to fill it
// has to arrive in the payload or the insert dies.
mustSupply: row.notnull === 1 && row.dflt_value === null && row.pk === 0,
notNullable: row.notnull === 1 && row.pk === 0,
});
}
return meta;
}
/* Every table the descriptor writes to, with the columns it
declares and the ones the engine fills in on its own. */
function collectGroups(entity) {
const groups = [
{
table: entity.table,
columns: entity.columns ?? [],
engineSupplied: [entity.idColumn],
},
];
for (const ext of entity.extensions ?? []) {
groups.push({
table: ext.table,
columns: ext.columns ?? [],
engineSupplied: [ext.idColumn, ...(ext.touch ? ["updated_at"] : [])],
});
}
const walk = (child) => {
groups.push({
table: child.table,
columns: child.columns ?? [],
engineSupplied: [
child.owner.column,
child.owner.kindColumn,
"sort_order",
...Object.keys(child.owner.inherit ?? {}),
].filter(Boolean),
});
for (const nested of child.children ?? []) walk(nested);
};
for (const child of entity.children ?? []) walk(child);
return groups;
}
export function syncDescriptorsWithSchema(db, entities, { strict = true } = {}) {
const list = Array.isArray(entities) ? entities.map((e) => [e.name ?? e.table, e]) : Object.entries(entities);
const inferred = [];
const problems = [];
for (const [name, entity] of list) {
for (const group of collectGroups(entity)) {
const meta = tableMeta(db, group.table);
if (!meta) {
problems.push(`${name}: table "${group.table}" does not exist.`);
continue;
}
const declared = new Set(group.columns.map((column) => column.name));
for (const column of group.columns) {
const info = meta.get(column.name);
if (!info) {
problems.push(
`${name}: ${group.table}.${column.name} is in the descriptor but not in the table.`,
);
continue;
}
if (info.mustSupply && !column.required) {
column.required = true;
inferred.push(`${name}: ${group.table}.${column.name}`);
}
if (info.notNullable) column.notNullable = true;
}
for (const [columnName, info] of meta) {
if (!info.mustSupply) continue;
if (declared.has(columnName)) continue;
if (group.engineSupplied.includes(columnName)) continue;
problems.push(
`${name}: ${group.table}.${columnName} is NOT NULL with no default, ` +
`but no descriptor column covers it — every insert will fail.`,
);
}
}
}
if (inferred.length) {
console.warn(
`[admin-schema] marked required from the schema (add required: true to the descriptor):\n ${inferred.join("\n ")}`,
);
}
if (problems.length) {
const report = `[admin-schema] descriptor does not match the database:\n ${problems.join("\n ")}`;
if (strict) throw new Error(report);
console.error(report);
}
return { inferred, problems };
}

518
server/src/admin-schema.js Normal file
View file

@ -0,0 +1,518 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY DESCRIPTORS
One object per editable entity. Everything the CRUD handlers do
— validation, SQL, nesting — is read from here, so adding a
table later is a descriptor rather than another set of
hand-written statements to keep in step with the schema.
Anatomy of a descriptor:
columns writable columns of the parent row
extensions 1:1 side tables, optionally gated on a column
value (organizations.kind decides whether a
regions or chapters row should exist)
children ordered collections, replaced wholesale on save
Replacing children wholesale is only safe because nothing has a
foreign key INTO these tables. That is the dividing line, and
it is why teams and awards are entities of their own rather
than repeaters on the organization form: affiliations.team_id
and person_awards.award_id point at them, so a delete-and-
reinsert save would abort the moment either had a single
dependent row.
Parent ids are immutable. Polymorphic children reference their
owner by free-text owner_id, so renaming a slug in place would
silently orphan every link and content block attached to it.
affiliations is edited from both ends — a person's roles, and a
team's members. Each side deletes and reinserts only its own
slice (WHERE person_id = ?, WHERE team_id = ?) and declares
every column of the row, so a save from one side round-trips
what the other side owns rather than blanking it.
═══════════════════════════════════════════════════════════════ */
/* ── Column helpers ──────────────────────────────────────────── */
const text = (name, opts = {}) => ({ name, type: "text", ...opts });
const int = (name, opts = {}) => ({ name, type: "int", ...opts });
const real = (name, opts = {}) => ({ name, type: "real", ...opts });
const bool = (name, opts = {}) => ({ name, type: "bool", ...opts });
const date = (name, opts = {}) => ({ name, type: "date", ...opts });
const enumeration = (name, values, opts = {}) => ({
name,
type: "enum",
values,
...opts,
});
/* Place columns shared by organizations and events, in schema order. */
const placeColumns = [
text("venue"),
text("address"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
real("latitude"),
real("longitude"),
bool("is_online"),
];
/* The affiliation's own fields, minus whichever end owns the row.
Both editors write the same shape so neither loses the other's
values on save. */
const affiliationRole = [
text("title"),
enumeration("role", ["lead", "board", "staff", "volunteer", "member"], {
required: true,
}),
bool("is_owner"),
date("started_on"),
date("ended_on"),
bool("is_public"),
];
/* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({
key: "links",
table: "links",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("kind", ["action", "social", "website", "email"], {
required: true,
}),
text("platform"),
text("label", { required: true }),
text("url", { required: true }),
bool("is_primary"),
],
});
const blocksChild = (ownerKind) => ({
key: "content_blocks",
table: "content_blocks",
owner: { column: "owner_id", kindColumn: "owner_kind", kindValue: ownerKind },
order: "sort_order",
columns: [
enumeration("slot", ["card", "body"], { required: true }),
enumeration(
"type",
[
"heading",
"subheading",
"paragraph",
"list",
"links",
"quote",
"image",
"divider",
],
{ required: true },
),
text("text"),
text("media"),
text("href"),
],
children: [
{
key: "items",
table: "content_block_items",
owner: { column: "block_id" },
order: "sort_order",
columns: [
text("text", { required: true }),
text("detail"),
text("url"),
],
},
],
});
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
key: "organizations",
table: "organizations",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"name",
"short_name",
"locality",
"state_code",
"is_published",
"sort_order",
"updated_at",
],
filters: ["kind", "is_published"],
search: ["name", "id", "locality"],
order: "kind, sort_order, name",
},
columns: [
enumeration("kind", ["national", "region", "chapter", "partner"], {
required: true,
}),
text("name", { required: true }),
text("short_name"),
text("tagline"),
text("color"),
text("logo"),
...placeColumns,
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "region",
table: "regions",
idColumn: "id",
when: { column: "kind", value: "region" },
columns: [
enumeration("scope", ["domestic", "international", "virtual"], {
required: true,
}),
text("map_note"),
],
},
{
key: "chapter",
table: "chapters",
idColumn: "id",
when: { column: "kind", value: "chapter" },
columns: [text("region_id"), text("meets"), text("started")],
},
],
children: [
linksChild("organization"),
blocksChild("organization"),
{
key: "region_areas",
table: "region_areas",
owner: { column: "region_id" },
when: { column: "kind", value: "region" },
order: "area_code",
columns: [
text("area_code", { required: true }),
real("share"),
enumeration("edge", ["top", "bottom"]),
text("note"),
],
},
],
};
/* ── Events ──────────────────────────────────────────────────── */
const events = {
key: "events",
table: "events",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"title",
"section_id",
"host_org_id",
"date_label",
"starts_on",
"status",
"is_published",
"sort_order",
"updated_at",
],
filters: ["section_id", "status", "is_published", "host_org_id"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("host_org_id"),
text("title", { required: true }),
text("theme"),
text("tagline"),
date("starts_on"),
date("ends_on"),
text("date_label"),
enumeration("status", ["upcoming", "past", "cancelled"]),
...placeColumns,
text("org_logo"),
text("event_logo"),
text("color"),
text("gradient"),
bool("is_published"),
int("sort_order"),
],
children: [
linksChild("event"),
blocksChild("event"),
{
key: "event_people",
table: "event_people",
owner: { column: "event_id" },
order: "sort_order",
columns: [
text("person_id", { required: true }),
enumeration(
"role",
[
"speaker",
"leader",
"facilitator",
"host",
"musician",
"volunteer",
"attendee",
],
{ required: true },
),
text("title"),
bool("is_public"),
],
},
],
};
/* ── People ──────────────────────────────────────────────────── */
const people = {
key: "people",
table: "people",
idColumn: "id",
idKind: "slug",
concurrency: "updated_at",
list: {
columns: [
"id",
"display_name",
"sort_name",
"tagline",
"locality",
"is_published",
"sort_order",
"updated_at",
],
filters: ["is_published"],
search: ["display_name", "sort_name", "id"],
order: "sort_order, sort_name, display_name",
},
columns: [
text("display_name", { required: true }),
text("sort_name"),
text("pronouns"),
text("tagline"),
text("photo"),
text("bio"),
text("primary_org_id"),
text("public_email"),
text("public_phone"),
text("locality"),
text("state_code"),
text("country"),
text("location_label"),
bool("is_published"),
int("sort_order"),
],
extensions: [
{
key: "private",
table: "person_private",
idColumn: "person_id",
touch: true, // has its own updated_at with no trigger behind it
columns: [
date("birth_date"),
text("private_email"),
text("private_phone"),
text("address"),
text("notes"),
],
},
],
children: [
linksChild("person"),
blocksChild("person"),
{
key: "affiliations",
table: "affiliations",
owner: { column: "person_id" },
// sort_order is where this person sits on that team, so it
// belongs to the team's editor. reindex: false stops this
// form renumbering by row position; declaring the column
// keeps the team's value intact across a save here.
reindex: false,
order: "org_id, team_id, sort_order",
columns: [
text("org_id", { required: true }),
text("team_id"),
...affiliationRole,
int("sort_order"),
],
},
{
key: "person_awards",
table: "person_awards",
owner: { column: "person_id" },
order: "awarded_on",
columns: [
text("award_id", { required: true }),
text("event_id"),
date("awarded_on"),
text("citation"),
bool("is_public"),
],
},
],
};
/* ── Teams ───────────────────────────────────────────────────── */
// A team belongs to exactly one organization, and affiliations
// point at the pair (team_id, org_id) rather than the team alone.
// Two consequences the form has to live with:
//
// · org_id cannot be changed once anyone is filed under the
// team. The composite foreign key has no ON UPDATE CASCADE, so
// SQLite aborts the UPDATE. That surfaces as a constraint
// error, which is the correct answer — reassign the members
// first.
//
// · deleting a team with members fails the same way, rather than
// quietly detaching them. Emptying the members list first is
// now something the form can do.
//
// No concurrency column: teams have no updated_at. Adding one
// means rebuilding a STRICT table for a row that one person edits
// at a time, which is not a trade worth making yet.
const teams = {
key: "teams",
table: "teams",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "tagline", "is_published", "sort_order"],
filters: ["org_id", "is_published"],
search: ["name", "id", "tagline"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id", { required: true }),
text("name", { required: true }),
text("tagline"),
text("color"),
text("logo"),
bool("is_published"),
int("sort_order"),
],
children: [
// 'team' is already a valid owner_kind in both polymorphic
// tables, and teams_cleanup drops the rows on delete, so a team
// page comes free.
linksChild("team"),
blocksChild("team"),
{
key: "members",
table: "affiliations",
// org_id is inherited from the team rather than asked for:
// the composite foreign key (team_id, org_id) means a member
// of this team can only belong to this team's organization,
// so a second dropdown could only ever be wrong.
owner: { column: "team_id", inherit: { org_id: "org_id" } },
// Row position is the order they appear on the public site.
// This is the only editor that writes it.
order: "sort_order",
columns: [text("person_id", { required: true }), ...affiliationRole],
},
],
};
/* ── Awards ──────────────────────────────────────────────────── */
// org_id is who gives the award, added in 004. Nullable, because
// an award can predate any decision about which organization owns
// it, and because person_awards rows must survive the awarding
// org being deleted.
//
// No children: 'award' is not in the owner_kind CHECK on
// content_blocks or links. If awards ever need a page of their
// own, that CHECK is a table rebuild, so decide before adding one
// rather than after.
const awards = {
key: "awards",
table: "awards",
idColumn: "id",
idKind: "slug",
list: {
columns: ["id", "org_id", "name", "description", "sort_order"],
filters: ["org_id"],
search: ["name", "id", "description"],
order: "org_id, sort_order, name",
},
columns: [
text("org_id"),
text("name", { required: true }),
text("description"),
text("logo"),
int("sort_order"),
],
};
export const ENTITIES = { organizations, events, people, teams, awards };
/* ── Options for the form's select inputs ────────────────────── */
export const OPTION_QUERIES = {
organizations:
"SELECT id, name AS label, kind FROM organizations ORDER BY kind, name",
regions:
"SELECT id, name AS label FROM organizations WHERE kind = 'region' ORDER BY name",
event_sections: "SELECT id, name AS label FROM event_sections ORDER BY sort_order",
events: "SELECT id, title AS label FROM events ORDER BY starts_on DESC, title",
people: "SELECT id, display_name AS label FROM people ORDER BY sort_name, display_name",
// org_id rides along so the affiliation row can filter the list
// down to teams of the organization it already names.
teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// The awarding organization is folded into the label instead,
// because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate
// when two orgs name an award the same thing.
awards: `
SELECT a.id,
CASE WHEN o.name IS NULL THEN a.name
ELSE a.name || ' — ' || o.name END AS label,
a.org_id
FROM awards a
LEFT JOIN organizations o ON o.id = a.org_id
ORDER BY a.sort_order, a.name`,
};

226
server/src/auth.js Normal file
View file

@ -0,0 +1,226 @@
/* ═══════════════════════════════════════════════════════════════
AUTH
Everything that decides who someone is. Routes decide what they
may do.
Password storage is scrypt from node:crypto — no native module,
nothing to compile, nothing for pnpm to get wrong. The stored
string carries its own parameters, so raising the cost later
doesn't invalidate existing hashes.
Sessions are opaque random tokens. The browser holds the token
in an HttpOnly cookie; the database holds only its SHA-256. A
leaked backup therefore contains no usable session, and signing
someone out is a DELETE rather than a wait for expiry.
═══════════════════════════════════════════════════════════════ */
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
export const COOKIE_NAME = "ngu_session";
const SESSION_DAYS = 30;
// Re-issue the expiry when a session is used with less than this
// left, so an active person is never signed out mid-task.
const REFRESH_WITHIN_DAYS = 7;
// Off only for plain-http local work. Production is behind TLS.
const SECURE_COOKIE = (process.env.COOKIE_SECURE ?? "true") !== "false";
const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex");
/* ── Passwords ─────────────────────────────────────────────────
Format: scrypt$N$r$p$saltHex$keyHex
N=16384 r=8 p=1 is the standard interactive cost, roughly
100ms per hash on this class of hardware.
───────────────────────────────────────────────────────────── */
const SCRYPT = { N: 16384, r: 8, p: 1, keyLen: 64 };
export function hashPassword(password) {
const salt = randomBytes(16);
const key = scryptSync(password.normalize("NFKC"), salt, SCRYPT.keyLen, {
N: SCRYPT.N,
r: SCRYPT.r,
p: SCRYPT.p,
// scrypt's default memory cap is below what N=16384 needs.
maxmem: 256 * 1024 * 1024,
});
return [
"scrypt",
SCRYPT.N,
SCRYPT.r,
SCRYPT.p,
salt.toString("hex"),
key.toString("hex"),
].join("$");
}
export function verifyPassword(password, stored) {
if (typeof stored !== "string") return false;
const [scheme, N, r, p, saltHex, keyHex] = stored.split("$");
if (scheme !== "scrypt") return false;
let candidate;
try {
candidate = scryptSync(
password.normalize("NFKC"),
Buffer.from(saltHex, "hex"),
Buffer.from(keyHex, "hex").length,
{ N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024 },
);
} catch {
return false;
}
const expected = Buffer.from(keyHex, "hex");
if (candidate.length !== expected.length) return false;
return timingSafeEqual(candidate, expected);
}
/* ── Hashing helpers ─────────────────────────────────────────── */
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
export function hashIp(ip) {
if (!ip) return null;
return sha256(`${IP_SALT}:${ip}`).slice(0, 32);
}
export function clientIp(c) {
// Trustworthy only because this service binds to 127.0.0.1 and
// nginx is the only thing that can reach it.
return c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? null;
}
/* ── Sessions ────────────────────────────────────────────────── */
export function createSession(db, c, userId) {
const token = randomBytes(32).toString("base64url");
db.prepare(
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, ip_hash)
VALUES (?, ?, datetime('now', ?), ?, ?)`,
).run(
sha256(token),
userId,
`+${SESSION_DAYS} days`,
c.req.header("user-agent")?.slice(0, 500) ?? null,
hashIp(clientIp(c)),
);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
// Lax, not Strict: Strict would drop the cookie when someone
// follows a link into /admin from elsewhere, which reads as
// being randomly signed out. Lax still blocks the cross-site
// POST that CSRF depends on.
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
return token;
}
export function destroySession(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (token) {
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token));
}
deleteCookie(c, COOKIE_NAME, { path: "/", secure: SECURE_COOKIE });
}
export function destroyAllSessionsFor(db, userId) {
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
}
/* Returns the signed-in user, or null. Also slides the expiry
forward when the session is getting old. */
export function currentUser(db, c) {
const token = getCookie(c, COOKIE_NAME);
if (!token) return null;
const tokenHash = sha256(token);
const row = db
.prepare(
`SELECT s.id AS session_id,
s.expires_at AS expires_at,
u.id, u.email, u.name, u.role
FROM sessions s
JOIN admin_users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.expires_at > datetime('now')
AND u.is_active = 1`,
)
.get(tokenHash);
if (!row) return null;
const refreshDue = db
.prepare("SELECT ? < datetime('now', ?) AS due")
.get(row.expires_at, `+${REFRESH_WITHIN_DAYS} days`);
if (refreshDue?.due) {
db.prepare(
`UPDATE sessions
SET expires_at = datetime('now', ?), last_seen_at = datetime('now')
WHERE id = ?`,
).run(`+${SESSION_DAYS} days`, row.session_id);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: SECURE_COOKIE,
sameSite: "Lax",
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
}
return { id: row.id, email: row.email, name: row.name, role: row.role };
}
/* ── Middleware ──────────────────────────────────────────────── */
export async function requireAuth(c, next) {
const user = currentUser(c.get("db"), c);
if (!user) return c.json({ error: "Not signed in." }, 401);
c.set("user", user);
await next();
}
export function requireRole(...roles) {
return async (c, next) => {
const user = c.get("user");
if (!user || !roles.includes(user.role)) {
return c.json({ error: "Not allowed." }, 403);
}
await next();
};
}
/* ── Housekeeping ──────────────────────────────────────────────
Expired rows are already ignored by every query; this just
stops the table growing without bound.
───────────────────────────────────────────────────────────── */
export function startSessionSweeper(db, everyMs = 6 * 60 * 60 * 1000) {
const sweep = () => {
try {
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
} catch (err) {
console.error("session sweep failed", err);
}
};
sweep();
setInterval(sweep, everyMs).unref();
}

View file

@ -15,7 +15,14 @@ import { logger } from "hono/logger";
import { openDatabase, migrate } from "./db.js";
import { rateLimit } from "./rateLimit.js";
import content from "./routes/content.js";
import people from "./routes/people.js";
import feedback from "./routes/feedback.js";
import auth from "./routes/auth.js";
import admin from "./routes/admin.js";
import adminEntities from "./routes/admin-entities.js";
import { startSessionSweeper } from "./auth.js";
import { syncDescriptorsWithSchema } from "./admin-schema-sync.js";
import { ENTITIES } from "./admin-schema.js";
const HOST = process.env.HOST ?? "127.0.0.1";
const PORT = Number(process.env.PORT ?? 3001);
@ -25,6 +32,7 @@ const DB_PATH = process.env.DB_PATH ?? "./ngu.db";
const db = await openDatabase(DB_PATH);
const version = migrate(db);
syncDescriptorsWithSchema(db, ENTITIES);syncDescriptorsWithSchema(db, ENTITIES);
console.log(`db ${DB_PATH} (${db.driverName}, schema v${version})`);
@ -44,11 +52,19 @@ app.get("/api/health", (c) =>
);
app.route("/api", content);
app.route("/api", people);
// Tighter limit on the write path than anything else gets.
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
app.route("/api/feedback", feedback);
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
app.route("/api/auth", auth);
app.route("/api/admin", admin);
app.route("/api/admin", adminEntities);
startSessionSweeper(db);
app.notFound((c) => c.json({ error: "Not found" }, 404));
app.onError((err, c) => {

View file

@ -0,0 +1,55 @@
-- ═══════════════════════════════════════════════════════════════
-- 003 AUTHENTICATION
--
-- Two tables: who may sign in, and who currently is signed in.
--
-- There is no self-signup and no registration endpoint. Accounts
-- are created from the CLI, on the box, by someone with shell
-- access. For a handful of staff that's the right trade: no
-- invite flow, no email delivery, no password-reset surface for
-- anyone to attack.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- Stored lowercased. The application lowercases on every read
-- and write, so the UNIQUE index is genuinely case-insensitive
-- without depending on a collation.
email TEXT NOT NULL UNIQUE,
name TEXT,
-- Nullable so a Google-only account can exist later with no
-- password at all. A row with both can use either route in.
password_hash TEXT,
-- Google's stable subject id. Nullable, unique when present —
-- SQLite allows any number of NULLs in a unique index.
google_sub TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'viewer')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT
) STRICT;
-- One row per active login. The cookie holds a random token; this
-- table holds only its SHA-256, so a database leak doesn't hand
-- anyone a working session.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT,
ip_hash TEXT
) STRICT;
CREATE INDEX sessions_user_idx ON sessions (user_id);
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);

View file

@ -0,0 +1,7 @@
-- 004_award_org.sql
-- Who gave the award. SET NULL rather than CASCADE: retiring a
-- partner org shouldn't erase an award people have received.
ALTER TABLE awards
ADD COLUMN org_id TEXT REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX awards_org_idx ON awards (org_id, sort_order);

View file

@ -0,0 +1,28 @@
-- ═══════════════════════════════════════════════════════════════
-- Person bio and primary organization
--
-- bio is one run of prose, not orderable mixed content, so it does
-- not belong in content_blocks — whose owner_kind CHECK would need
-- a full table rebuild to accept 'person' anyway. Paragraphs are
-- blank-line separated and split at render time.
--
-- primary_org_id is nullable on purpose: plenty of people have no
-- home organization worth printing, and ON DELETE SET NULL means
-- deleting an org blanks the reference rather than blocking the
-- delete or leaving a dangling id behind.
--
-- Check the current version before renumbering this file:
-- PRAGMA user_version;
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE people ADD COLUMN bio TEXT;
-- SQLite requires an added REFERENCES column to default to NULL,
-- which is what we want regardless.
ALTER TABLE people ADD COLUMN primary_org_id TEXT
REFERENCES organizations (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS people_primary_org
ON people (primary_org_id);
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -0,0 +1,45 @@
-- ═══════════════════════════════════════════════════════════════
-- v_org_leadership: add bio and primary organization
--
-- The view already carries the rules for who counts as current and
-- public. Adding the two columns the people tiles need keeps those
-- rules in one place instead of being restated by each route.
--
-- Additive only — attachLeadership does SELECT * and shapeLeader
-- picks fields by name, so existing callers are unaffected.
--
-- PRAGMA user_version; -- check before renumbering this file
-- ═══════════════════════════════════════════════════════════════
DROP VIEW IF EXISTS v_org_leadership;
CREATE VIEW v_org_leadership AS
SELECT
a.org_id,
a.team_id,
t.name AS team_name,
t.sort_order AS team_sort_order,
a.person_id,
a.title,
a.role,
a.is_owner,
a.sort_order,
p.display_name,
p.sort_name,
p.pronouns,
p.tagline,
p.photo,
p.public_email,
p.location_label,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM affiliations a
JOIN people p ON p.id = a.person_id AND p.is_published = 1
LEFT JOIN teams t ON t.id = a.team_id
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE a.is_public = 1
AND a.ended_on IS NULL
ORDER BY a.org_id, a.is_owner DESC, a.sort_order, p.sort_name;
PRAGMA user_version = 0; -- ← set to this migration's number

View file

@ -0,0 +1,111 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ENTITY ROUTES
GET /api/admin/options
GET /api/admin/:entity list, filtered and searched
GET /api/admin/:entity/:id row with side tables and children
POST /api/admin/:entity create
PATCH /api/admin/:entity/:id replace the row and its children
DELETE /api/admin/:entity/:id
Mounted alongside the existing admin router, which keeps the
feedback routes. Both sit under the same requireAuth.
:entity is matched against the descriptor map, never
interpolated into SQL from the URL — an unknown name is a 404
before anything touches the database.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { requireAuth, requireRole } from "../auth.js";
import { ENTITIES, OPTION_QUERIES } from "../admin-schema.js";
import {
HttpError,
listRows,
readRow,
createRow,
updateRow,
deleteRow,
} from "../admin-crud.js";
const entities = new Hono();
entities.use("*", requireAuth);
const NO_STORE = { "Cache-Control": "no-store" };
function entityOr404(c) {
const entity = ENTITIES[c.req.param("entity")];
if (!entity) throw new HttpError(404, "Unknown entity.");
return entity;
}
/* Everything the select inputs need, in one request. Small enough
that paging it would cost more than it saves. */
entities.get("/options", (c) => {
const db = c.get("db");
const options = {};
for (const [key, sql] of Object.entries(OPTION_QUERIES)) {
options[key] = db.prepare(sql).all();
}
return c.json({ options }, 200, NO_STORE);
});
entities.get("/:entity", (c) => {
const entity = entityOr404(c);
const { rows, total } = listRows(c.get("db"), entity, c.req.query());
return c.json({ rows, total }, 200, NO_STORE);
});
entities.get("/:entity/:id", (c) => {
const entity = entityOr404(c);
return c.json({ row: readRow(c.get("db"), entity, c.req.param("id")) }, 200, NO_STORE);
});
entities.post("/:entity", requireRole("admin"), async (c) => {
const entity = entityOr404(c);
const row = createRow(c.get("db"), entity, await json(c));
console.log(`${entity.key} ${row[entity.idColumn]} created by ${c.get("user").email}`);
return c.json({ row }, 201, NO_STORE);
});
entities.patch("/:entity/:id", requireRole("admin"), async (c) => {
const entity = entityOr404(c);
const id = c.req.param("id");
const row = updateRow(c.get("db"), entity, id, await json(c));
console.log(`${entity.key} ${id} updated by ${c.get("user").email}`);
return c.json({ row }, 200, NO_STORE);
});
entities.delete("/:entity/:id", requireRole("admin"), (c) => {
const entity = entityOr404(c);
const id = c.req.param("id");
deleteRow(c.get("db"), entity, id);
console.log(`${entity.key} ${id} deleted by ${c.get("user").email}`);
return c.body(null, 204);
});
async function json(c) {
try {
return await c.req.json();
} catch {
throw new HttpError(400, "Expected a JSON body.");
}
}
/* Turn HttpError into a response here rather than letting the
app-level handler flatten everything to a 500. */
entities.onError((err, c) => {
if (err instanceof HttpError) {
return c.json(
err.fields ? { error: err.message, fields: err.fields } : { error: err.message },
err.status,
NO_STORE,
);
}
console.error(err);
return c.json({ error: "Something went wrong." }, 500);
});
export default entities;

172
server/src/routes/admin.js Normal file
View file

@ -0,0 +1,172 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN ROUTES
Everything under /api/admin requires a session. Writes require
the 'admin' role; a 'viewer' can read and nothing else.
No response from here is cacheable, and none of it should ever
sit in a proxy.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { requireAuth, requireRole } from "../auth.js";
const admin = new Hono();
admin.use("*", requireAuth);
const NO_STORE = { "Cache-Control": "no-store" };
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
const MAX_LIMIT = 200;
const NOTE_LIMIT = 2000;
/* ── GET /api/admin/feedback ─────────────────────────────────────
?status=new one of STATUSES, or omitted for all
?type=broken feedback_type
?q=retreat substring of the message
?before=41 cursor: rows with a lower id than this
?limit=50
───────────────────────────────────────────────────────────── */
admin.get("/feedback", (c) => {
const db = c.get("db");
const { status, type, q, before, limit } = c.req.query();
const where = [];
const params = [];
if (STATUSES.includes(status)) {
where.push("status = ?");
params.push(status);
}
if (type) {
where.push("feedback_type = ?");
params.push(type.slice(0, 40));
}
if (q) {
where.push("message LIKE ?");
params.push(`%${q.slice(0, 100)}%`);
}
if (before && Number.isInteger(Number(before))) {
where.push("id < ?");
params.push(Number(before));
}
const take = Math.min(Number(limit) || 50, MAX_LIMIT);
const rows = db
.prepare(
`SELECT id, created_at, feedback_type, message, name, email,
page_path, section_id, status, admin_note
FROM feedback
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY id DESC
LIMIT ?`,
)
.all(...params, take + 1); // one extra to detect a next page
const hasMore = rows.length > take;
const page = hasMore ? rows.slice(0, take) : rows;
// Counts are unfiltered on purpose: the tabs should show what's
// waiting overall, not what's left after the current filter.
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]));
for (const row of db
.prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status")
.all()) {
counts[row.status] = row.n;
}
return c.json(
{
feedback: page,
counts,
nextCursor: hasMore ? page[page.length - 1].id : null,
},
200,
NO_STORE,
);
});
/* ── PATCH /api/admin/feedback/:id ───────────────────────────────
{ status?, admin_note? } — either, both, partial.
───────────────────────────────────────────────────────────── */
admin.patch("/feedback/:id", requireRole("admin"), async (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
const sets = [];
const params = [];
if (body.status !== undefined) {
if (!STATUSES.includes(body.status)) {
return c.json({ error: "Unknown status." }, 422);
}
sets.push("status = ?");
params.push(body.status);
}
if (body.admin_note !== undefined) {
const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT);
sets.push("admin_note = ?");
params.push(note || null);
}
if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400);
const result = db_update(c.get("db"), id, sets, params);
if (!result) return c.json({ error: "No such feedback." }, 404);
console.log(`feedback #${id} updated by ${c.get("user").email}`);
return c.json({ feedback: result }, 200, NO_STORE);
});
function db_update(db, id, sets, params) {
const changed = db
.prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`)
.run(...params, id);
if (changed.changes === 0) return null;
return db
.prepare(
`SELECT id, created_at, feedback_type, message, name, email,
page_path, section_id, status, admin_note
FROM feedback WHERE id = ?`,
)
.get(id);
}
/* ── DELETE /api/admin/feedback/:id ──────────────────────────────
Actually gone. Marking something 'spam' is the reversible
option and should be the habit; this is for the cases where
the content itself shouldn't stay on disk.
───────────────────────────────────────────────────────────── */
admin.delete("/feedback/:id", requireRole("admin"), (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400);
const result = c
.get("db")
.prepare("DELETE FROM feedback WHERE id = ?")
.run(id);
if (result.changes === 0) return c.json({ error: "No such feedback." }, 404);
console.log(`feedback #${id} deleted by ${c.get("user").email}`);
return c.body(null, 204);
});
export default admin;

99
server/src/routes/auth.js Normal file
View file

@ -0,0 +1,99 @@
/* ═══════════════════════════════════════════════════════════════
AUTH ROUTES
POST /api/auth/login email + password → session cookie
POST /api/auth/logout deletes the session row and cookie
GET /api/auth/me who am I, or 401
Three deliberate choices:
Login is rate limited where it's mounted, and answers wrong
password and unknown account identically. Telling an attacker
which addresses exist is free reconnaissance.
A failed login still runs a hash. Otherwise the response time
itself says whether the account exists.
Nothing here creates accounts. See admin-cli.js.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import {
createSession,
destroySession,
currentUser,
hashPassword,
verifyPassword,
} from "../auth.js";
const auth = new Hono();
// A real hash to compare against when the account doesn't exist,
// so both paths cost the same. Computed once at import.
const DUMMY_HASH = hashPassword("this password is never correct");
const NO_STORE = { "Cache-Control": "no-store" };
auth.post("/login", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Expected a JSON body." }, 400);
}
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 254);
const password = String(body.password ?? "");
if (!email || !password) {
return c.json({ error: "Email and password are required." }, 400);
}
const db = c.get("db");
const user = db
.prepare(
`SELECT id, email, name, role, password_hash, is_active
FROM admin_users
WHERE email = ?`,
)
.get(email);
const ok =
user && user.is_active === 1
? verifyPassword(password, user.password_hash)
: (verifyPassword(password, DUMMY_HASH), false);
if (!ok) {
console.warn(`login failed for ${email}`);
return c.json({ error: "That email and password don't match." }, 401);
}
createSession(db, c, user.id);
db.prepare("UPDATE admin_users SET last_login_at = datetime('now') WHERE id = ?").run(
user.id,
);
console.log(`login ${user.email}`);
return c.json(
{ user: { id: user.id, email: user.email, name: user.name, role: user.role } },
200,
NO_STORE,
);
});
auth.post("/logout", (c) => {
destroySession(c.get("db"), c);
return c.body(null, 204);
});
auth.get("/me", (c) => {
const user = currentUser(c.get("db"), c);
if (!user) return c.json({ error: "Not signed in." }, 401, NO_STORE);
return c.json({ user }, 200, NO_STORE);
});
export default auth;

141
server/src/routes/people.js Normal file
View file

@ -0,0 +1,141 @@
/* ═══════════════════════════════════════════════════════════════
PEOPLE ROUTES — read-only, mounted under /api
GET /teams/:id/people current public members of a team
GET /people?ids=a,b,c named people, any order
The team route reads v_org_leadership, which already decides who
counts as current and public — affiliation still open, marked
public, person published. Restating those conditions here is how
they drift apart.
That view is affiliation-driven, so it can't serve the lookup
route: a person with no affiliations would vanish, and one with
several would appear more than once. The lookup reads people
directly and returns no title, because a bare slug names no seat.
Photos are filenames, as everywhere else in this API. The
component decides where they live.
═══════════════════════════════════════════════════════════════ */
import { Hono } from "hono";
import { asBool } from "../shape.js";
const people = new Hono();
const CACHE = "public, max-age=60, stale-while-revalidate=300";
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
const marks = (n) => Array(n).fill("?").join(",");
/* How many slugs one request may name. Keeps the URL sane. */
const MAX_IDS = 50;
function shapePerson(row) {
return {
id: row.id ?? row.person_id,
name: row.display_name,
// Only the team route knows a seat; the lookup route doesn't.
title: row.title ?? null,
tagline: row.tagline,
pronouns: row.pronouns,
photo: row.photo,
location_label: row.location_label,
public_email: row.public_email,
org: row.primary_org_id
? { id: row.primary_org_id, name: row.primary_org_name }
: null,
bio: splitParagraphs(row.bio),
role: row.role ?? null,
is_owner: row.is_owner === undefined ? false : asBool(row.is_owner),
};
}
/* people.bio is one run of prose with blank lines between
paragraphs — unrelated to shape.js's paragraphs(), which turns
content_block rows into text. */
function splitParagraphs(text) {
if (!text) return null;
const parts = text
.split(/\n\s*\n/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length ? parts : null;
}
/* ── One team's people ─────────────────────────────────────── */
people.get("/teams/:id/people", (c) => {
const db = c.get("db");
const id = c.req.param("id");
const team = db
.prepare(
`SELECT id, org_id, name, tagline, color, logo
FROM teams
WHERE id = ? AND is_published = 1`,
)
.get(id);
if (!team) return c.json({ error: "No such team" }, 404);
// The view orders by org first, which isn't what a single team
// wants, so the order is restated here.
const rows = db
.prepare(
`SELECT * FROM v_org_leadership
WHERE team_id = ?
ORDER BY is_owner DESC,
sort_order,
COALESCE(sort_name, display_name),
display_name`,
)
.all(id);
return json(c, { team, people: rows.map(shapePerson) });
});
/* ── People by id ──────────────────────────────────────────────
For hand-picked lists in a section. Order is the caller's, so
there's no ORDER BY here.
───────────────────────────────────────────────────────────── */
people.get("/people", (c) => {
const db = c.get("db");
const ids = [
...new Set(
(c.req.query("ids") ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean),
),
].slice(0, MAX_IDS);
if (ids.length === 0) return json(c, { people: [] });
const rows = db
.prepare(
`SELECT p.id,
p.display_name,
p.pronouns,
p.photo,
p.tagline,
p.location_label,
p.public_email,
p.bio,
o.id AS primary_org_id,
o.name AS primary_org_name
FROM people p
LEFT JOIN organizations o ON o.id = p.primary_org_id
WHERE p.id IN (${marks(ids.length)})
AND p.is_published = 1`,
)
.all(...ids);
return json(c, { people: rows.map(shapePerson) });
});
export default people;