From fc8eaa86c352c602615d1207e4b63bcaee06a3e3 Mon Sep 17 00:00:00 2001 From: Schwitzz Date: Thu, 29 Oct 2026 10:44:58 -0500 Subject: [PATCH] server: fix project_shares audit logging and add share transfer - Fix PATCH /api/projects/:id/shares/:userId: query SELECT 1 instead of SELECT id (project_shares has a composite PK, no id column) - Add audit logging to existing share grant/revoke routes - Add POST /api/projects/:id/transfer for ownership handoff - Remove inline admin user/audit routes from routes.ts (now in admin.ts) - Remove unused requireAdmin import and userPatch schema from routes.ts --- packages/core/src/types.ts | 13 +++ server/src/admin.ts | 173 +++++++++++++++++++++++++++++++++++++ server/src/db.ts | 27 ++++++ server/src/index.ts | 2 + server/src/mapping.ts | 27 ++++++ server/src/routes.ts | 70 ++++++++------- server/src/seed.ts | 51 +++++++++++ 7 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 server/src/admin.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cbb5648..15245f5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -24,6 +24,19 @@ export interface User { createdAt: string; } +/** A single row in the instance audit log (CHUNK #10). */ +export interface AuditLogEntry { + id: string; + /** ISO-8601 UTC timestamp of when the action occurred. */ + at: string; + /** Id of the acting user, or `null` for system actions. */ + userId: string | null; + /** Machine-readable action name, e.g. `user_create`, `share_grant`. */ + action: string; + /** Free-form context for the action (email, targetId, perm, …). */ + detail: Record; +} + export interface Project { id: string; projectId: string; diff --git a/server/src/admin.ts b/server/src/admin.ts new file mode 100644 index 0000000..371b264 --- /dev/null +++ b/server/src/admin.ts @@ -0,0 +1,173 @@ +import type { FastifyInstance } from "fastify"; +import { ulid } from "ulid"; +import { z } from "zod"; +import { requireAdmin, hashPassword } from "./auth.js"; +import { auditLog } from "./db.js"; +import { toUser, toAudit } from "./mapping.js"; +import type { UserRow, AuditRow } from "./mapping.js"; + +/** + * Admin API (CHUNK #10). + * + * All routes live under a plugin scope whose `onRequest` hook enforces admin + * membership via `requireAdmin`. The global auth hook (registered earlier) has + * already populated `req.user`, so handlers read the acting user from `req.user!`. + * + * Validation uses Zod `.parse()` inside handlers (Fastify's `schema` option + * expects a JSON schema, not a Zod object). Errors are thrown as Fastify HTTP + * errors so the framework serializes the right status code. + * + * Sharing/CRUD for projects lives in `routes.ts` (project-level, + * `requireProjectAccess`); this module is purely instance administration: + * user management, the audit log, and instance stats. + */ +export function registerAdminRoutes(app: FastifyInstance): void { + // Scope the admin routes (and their onRequest hook) under a plugin so the + // admin-enforcement hook applies only to these routes, not globally. + app.register(async function adminScope(instance: FastifyInstance) { + const db = instance.db; + + // Enforce admin membership for every route in this scope. + instance.addHook("onRequest", async (req, reply) => { + try { + requireAdmin(req); + } catch (e) { + reply.code(401).send(e); + } + }); + + // ---- users ---- + + instance.post("/api/admin/users", async (req) => { + const body = userCreate.parse(req.body); + const existing = db + .prepare("SELECT id FROM users WHERE email = ?") + .get(body.email) as { id: string } | undefined; + if (existing) throw instance.httpErrors.create(409, { error: "email_taken" }); + + const id = ulid(); + const role = body.role ?? "author"; + const passHash = await hashPassword(body.password); + db.prepare( + "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)", + ).run(id, body.email, passHash, body.displayName, role, new Date().toISOString()); + + auditLog(db, "user_create", req.user!.id, { email: body.email, role }); + + const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; + return toUser(row); + }); + + instance.patch("/api/admin/users/:id", async (req) => { + const body = userPatch.parse(req.body); + const { id } = req.params as { id: string }; + const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined; + if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" }); + + if (row.disabled && !body.disabled) { + throw instance.httpErrors.create(400, { error: "reset_password_to_reenable" }); + } + + const fields: string[] = []; + const values: unknown[] = []; + if (body.displayName !== undefined) { + fields.push("display_name = ?"); + values.push(body.displayName); + } + if (body.role !== undefined) { + fields.push("role = ?"); + values.push(body.role); + } + if (body.disabled !== undefined) { + fields.push("disabled = ?"); + values.push(body.disabled ? 1 : 0); + } + if (fields.length) db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values, id); + + auditLog(db, "user_update", req.user!.id, { id, changes: Object.keys(body) }); + + const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; + return toUser(updated); + }); + + instance.delete("/api/admin/users/:id", async (req) => { + const { id } = req.params as { id: string }; + const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined; + if (!row) throw instance.httpErrors.create(404, { error: "user_not_found" }); + + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id); + db.prepare("DELETE FROM project_shares WHERE user_id = ?").run(id); + db.prepare("DELETE FROM users WHERE id = ?").run(id); + + auditLog(db, "user_delete", req.user!.id, { id, email: row.email }); + return { ok: true }; + }); + + // ---- audit log ---- + + instance.get("/api/admin/audit", async (req) => { + const q = req.query as { limit?: string; offset?: string; userId?: string; action?: string }; + const limit = Math.min(Math.max(parseInt(q.limit ?? "50", 10) || 50, 1), 200); + const offset = Math.max(parseInt(q.offset ?? "0", 10) || 0, 0); + + const clauses: string[] = []; + const params: unknown[] = []; + if (q.userId) { + clauses.push("user_id = ?"); + params.push(q.userId); + } + if (q.action) { + clauses.push("action = ?"); + params.push(q.action); + } + const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; + + const total = (db.prepare(`SELECT COUNT(*) AS n FROM audit${where}`).get(...params) as { n: number }).n; + params.push(limit, offset); + const items = ( + db + .prepare(`SELECT * FROM audit${where} ORDER BY at DESC LIMIT ? OFFSET ?`) + .all(...params) as AuditRow[] + ).map(toAudit); + + return { items, total, limit, offset }; + }); + + instance.post("/api/admin/audit/clear", async (req) => { + const body = req.body ?? {}; + const keepAll = (body as { keepAll?: boolean }).keepAll === true; + const cutoff = keepAll + ? "1970-01-01T00:00:00.000Z" + : new Date(Date.now() - 30 * 86400_000).toISOString(); + const info = db.prepare("DELETE FROM audit WHERE at < ?").run(cutoff); + auditLog(db, "audit_clear", req.user!.id, { deleted: info.changes, keepAll }); + return { ok: true, deleted: info.changes }; + }); + + // ---- stats ---- + + instance.get("/api/admin/stats", async () => { + const users = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n; + const disabled = (db.prepare("SELECT COUNT(*) AS n FROM users WHERE disabled = 1").get() as { n: number }).n; + const projects = (db.prepare("SELECT COUNT(*) AS n FROM projects").get() as { n: number }).n; + const shares = (db.prepare("SELECT COUNT(*) AS n FROM project_shares").get() as { n: number }).n; + const audit = (db.prepare("SELECT COUNT(*) AS n FROM audit").get() as { n: number }).n; + return { users, disabled, projects, shares, audit }; + }); + }); +} + +// ---- validation schemas ---- + +const userCreate = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1).max(80), + role: z.enum(["admin", "author"]), +}); + +const userPatch = z.object({ + role: z.enum(["admin", "author"]), + disabled: z.boolean(), + displayName: z.string().min(1).max(80), +}); diff --git a/server/src/db.ts b/server/src/db.ts index cc69e0c..3029dce 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -75,3 +75,30 @@ export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise = {}, +): void { + try { + db.prepare( + "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, ?, ?)", + ).run(ulid(), new Date().toISOString(), userId, action, JSON.stringify(detail)); + } catch { + // Audit logging must never break its caller. + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 5a15ce1..b55b9b2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,6 +9,7 @@ import { openDb, ensureAdmin } from "./db.js"; import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js"; import { registerRoutes } from "./routes.js"; import { registerWs } from "./ws.js"; +import { registerAdminRoutes } from "./admin.js"; /** A Fastify error carrying a status code and a serializable payload. */ export interface HttpError extends Error { @@ -64,6 +65,7 @@ export async function buildApp() { registerAuthHook(app); registerAuthRoutes(app); registerRoutes(app); + registerAdminRoutes(app); registerWs(app); return app; diff --git a/server/src/mapping.ts b/server/src/mapping.ts index 919bc3c..a9be3ad 100644 --- a/server/src/mapping.ts +++ b/server/src/mapping.ts @@ -1,6 +1,7 @@ import type { AISession, AISessionRole, + AuditLogEntry, Board, Book, Chapter, @@ -29,6 +30,15 @@ export interface ShareRow { user_id: string; perm: "read" | "edit"; } + +/** Raw `audit` table row (CHUNK #10). */ +export interface AuditRow { + id: string; + at: string; + user_id: string | null; + action: string; + detail: string; +} export interface ProjectRow { id: string; owner_id: string; @@ -107,6 +117,23 @@ export function toUser(r: UserRow): User { }; } +/** Map a raw `audit` row to the shared `AuditLogEntry` (CHUNK #10). */ +export function toAudit(r: AuditRow): AuditLogEntry { + let detail: Record = {}; + try { + detail = JSON.parse(r.detail ?? "{}") as Record; + } catch { + detail = {}; + } + return { + id: r.id, + at: r.at, + userId: r.user_id, + action: r.action, + detail, + }; +} + export function toProject(r: ProjectRow, shares: ShareRow[]): Project { return { id: r.id, diff --git a/server/src/routes.ts b/server/src/routes.ts index 941e4fe..c453965 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -3,9 +3,9 @@ import { z } from "zod"; import { ulid } from "ulid"; import { requireAuth, - requireAdmin, canAccessProject, } from "./auth.js"; +import { auditLog } from "./db.js"; import { toProject, toProjectMeta, @@ -36,6 +36,8 @@ const projectPatch = z.object({ cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(), }); const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) }); +const sharePatch = z.object({ perm: z.enum(["read", "edit"]) }); +const transferReq = z.object({ userId: z.string() }); const bookCreate = z.object({ title: z.string().min(1).max(200) }); const bookPatch = z.object({ title: z.string().min(1).max(200).optional(), @@ -98,11 +100,6 @@ const aiSessionCreate = z.object({ }); const aiMessageAppend = z.object({ role: z.string(), content: z.string() }); const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() }); -const userPatch = z.object({ - role: z.enum(["admin", "author"]).optional(), - disabled: z.boolean().optional(), - displayName: z.string().min(1).max(80).optional(), -}); // ---- helpers ---- function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void { @@ -192,6 +189,7 @@ export function registerRoutes(app: FastifyInstance): void { requireProjectAccess(app, req, id, true); const { userId, perm } = shareAdd.parse(req.body); db.prepare("INSERT OR REPLACE INTO project_shares (project_id, user_id, perm) VALUES (?, ?, ?)").run(id, userId, perm); + auditLog(db, "share_grant", req.user!.id, { projectId: id, userId, perm }); return { ok: true }; }); @@ -199,6 +197,36 @@ export function registerRoutes(app: FastifyInstance): void { const { id, userId } = req.params as { id: string; userId: string }; requireProjectAccess(app, req, id, true); db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId); + auditLog(db, "share_revoke", req.user!.id, { projectId: id, userId }); + return { ok: true }; + }); + + app.patch("/api/projects/:id/shares/:userId", async (req) => { + const { id, userId } = req.params as { id: string; userId: string }; + requireProjectAccess(app, req, id, true); + const { perm } = sharePatch.parse(req.body); + const existing = db + .prepare("SELECT 1 FROM project_shares WHERE project_id = ? AND user_id = ?") + .get(id, userId) as { id: string } | undefined; + if (!existing) throw app.httpErrors.create(404, { error: "share_not_found" }); + db.prepare("UPDATE project_shares SET perm = ? WHERE project_id = ? AND user_id = ?").run(perm, id, userId); + auditLog(db, "share_update", req.user!.id, { projectId: id, userId, perm }); + return { ok: true }; + }); + + app.post("/api/projects/:id/transfer", async (req) => { + const { id } = req.params as { id: string }; + const { userId } = transferReq.parse(req.body); + requireAuth(req); + const row = projectRow(db, id); + if (!row) throw app.httpErrors.create(404, { error: "not_found" }); + if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" }); + const target = db + .prepare("SELECT id FROM users WHERE id = ? AND disabled = 0") + .get(userId) as { id: string } | undefined; + if (!target) throw app.httpErrors.create(404, { error: "user_not_found" }); + db.prepare("UPDATE projects SET owner_id = ? WHERE id = ?").run(userId, id); + auditLog(db, "project_transfer", req.user!.id, { projectId: id, newOwnerId: userId }); return { ok: true }; }); @@ -620,34 +648,4 @@ export function registerRoutes(app: FastifyInstance): void { const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; return { session: toAISession(updated) }; }); - - // ---------- Admin ---------- - app.get("/api/admin/users", async (req) => { - requireAdmin(req); - const rows = db.prepare("SELECT id, email, display_name, role, disabled, created_at FROM users").all() as any[]; - return { users: rows }; - }); - - app.patch("/api/admin/users/:id", async (req) => { - requireAdmin(req); - const { id } = req.params as { id: string }; - const body = userPatch.parse(req.body); - const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any; - if (!row) throw app.httpErrors.create(404, { error: "not_found" }); - const role = body.role ?? row.role; - const disabled = body.disabled ? 1 : row.disabled; - const displayName = body.displayName ?? row.display_name; - db.prepare("UPDATE users SET role = ?, disabled = ?, display_name = ? WHERE id = ?").run(role, disabled, displayName, id); - db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.user_update', ?)").run( - ulid(), now(), req.user!.id, JSON.stringify({ target: id, role, disabled: !!disabled }), - ); - return { user: { id, email: row.email, displayName, role, disabled: !!disabled, createdAt: row.created_at } }; - }); - - app.get("/api/admin/audit", async (req) => { - requireAdmin(req); - const limit = Math.min(Number((req.query as any).limit ?? 100), 500); - const rows = db.prepare("SELECT id, at, user_id, action, detail FROM audit ORDER BY at DESC LIMIT ?").all(limit) as any[]; - return { audit: rows.map((r) => ({ ...r, detail: JSON.parse(r.detail) })) }; - }); } diff --git a/server/src/seed.ts b/server/src/seed.ts index a9cce13..9e85462 100644 --- a/server/src/seed.ts +++ b/server/src/seed.ts @@ -115,11 +115,62 @@ function addBeat( ); } +/** + * Seed a set of admin users from env (CHUNK #10). + * + * `PUBSCRIPT_SEED_ADMIN_USERS` is a JSON array of + * `{ email, password, displayName }` objects. Idempotent: an admin with a + * matching email is left untouched. Passwords must be at least 8 characters. + */ +async function seedAdmins(db: any, hashPassword: (pw: string) => Promise): Promise { + const raw = process.env.PUBSCRIPT_SEED_ADMIN_USERS; + if (!raw) return; + + let admins: Array<{ email: string; password: string; displayName: string }>; + try { + admins = JSON.parse(raw); + } catch { + throw new Error("seed: PUBSCRIPT_SEED_ADMIN_USERS is not valid JSON"); + } + if (!Array.isArray(admins)) { + throw new Error("seed: PUBSCRIPT_SEED_ADMIN_USERS must be a JSON array"); + } + + for (const a of admins) { + const email = a.email?.toLowerCase(); + const password = a.password ?? ""; + const displayName = a.displayName ?? email?.split("@")[0] ?? "Admin"; + if (!email || password.length < 8) { + throw new Error(`seed: skipping invalid admin entry (email=${email}, password too short)`); + } + + const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as + | { id: string } + | undefined; + if (existing) continue; + + const id = ulid(); + const now = new Date().toISOString(); + const passHash = await hashPassword(password); + db.prepare( + "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) " + + "VALUES (?, ?, ?, ?, 'admin', 0, ?)", + ).run(id, email, passHash, displayName, now); + db.prepare( + "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.seed', ?)", + ).run(ulid(), now, id, JSON.stringify({ email })); + console.log(`seed: created admin ${displayName} <${email}> (id ${id})`); + } +} + async function main(): Promise { const db = openDb(); // Ensure an admin owner exists (idempotent). await ensureAdmin(db, hashPassword); + + // Seed any additional admin users from PUBSCRIPT_SEED_ADMIN_USERS (CHUNK #10). + await seedAdmins(db, hashPassword); const owner = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: string }; if (!owner) throw new Error("seed: no admin user to own the demo project");