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
This commit is contained in:
Schwitzz
2026-10-29 10:44:58 -05:00
parent 1e16b0d5d2
commit fc8eaa86c3
7 changed files with 327 additions and 36 deletions
+13
View File
@@ -24,6 +24,19 @@ export interface User {
createdAt: string; 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<string, unknown>;
}
export interface Project { export interface Project {
id: string; id: string;
projectId: string; projectId: string;
+173
View File
@@ -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),
});
+27
View File
@@ -75,3 +75,30 @@ export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise<string
); );
}); });
} }
/**
* Record an admin action in the audit log.
*
* Never throws: audit logging must never break its caller (e.g. a route
* handler). Malformed detail is coerced to a JSON string; a DB failure is
* swallowed.
*
* @param db open database handle
* @param action machine-readable action name (e.g. `user_create`)
* @param userId id of the acting admin, or `null` for system actions
* @param detail free-form context object
*/
export function auditLog(
db: Db,
action: string,
userId: string | null,
detail: Record<string, unknown> = {},
): 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.
}
}
+2
View File
@@ -9,6 +9,7 @@ import { openDb, ensureAdmin } from "./db.js";
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js"; import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js"; import { registerWs } from "./ws.js";
import { registerAdminRoutes } from "./admin.js";
/** A Fastify error carrying a status code and a serializable payload. */ /** A Fastify error carrying a status code and a serializable payload. */
export interface HttpError extends Error { export interface HttpError extends Error {
@@ -64,6 +65,7 @@ export async function buildApp() {
registerAuthHook(app); registerAuthHook(app);
registerAuthRoutes(app); registerAuthRoutes(app);
registerRoutes(app); registerRoutes(app);
registerAdminRoutes(app);
registerWs(app); registerWs(app);
return app; return app;
+27
View File
@@ -1,6 +1,7 @@
import type { import type {
AISession, AISession,
AISessionRole, AISessionRole,
AuditLogEntry,
Board, Board,
Book, Book,
Chapter, Chapter,
@@ -29,6 +30,15 @@ export interface ShareRow {
user_id: string; user_id: string;
perm: "read" | "edit"; 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 { export interface ProjectRow {
id: string; id: string;
owner_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<string, unknown> = {};
try {
detail = JSON.parse(r.detail ?? "{}") as Record<string, unknown>;
} 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 { export function toProject(r: ProjectRow, shares: ShareRow[]): Project {
return { return {
id: r.id, id: r.id,
+34 -36
View File
@@ -3,9 +3,9 @@ import { z } from "zod";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { import {
requireAuth, requireAuth,
requireAdmin,
canAccessProject, canAccessProject,
} from "./auth.js"; } from "./auth.js";
import { auditLog } from "./db.js";
import { import {
toProject, toProject,
toProjectMeta, toProjectMeta,
@@ -36,6 +36,8 @@ const projectPatch = z.object({
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(), cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
}); });
const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) }); 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 bookCreate = z.object({ title: z.string().min(1).max(200) });
const bookPatch = z.object({ const bookPatch = z.object({
title: z.string().min(1).max(200).optional(), 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 aiMessageAppend = z.object({ role: z.string(), content: z.string() });
const aiSuggestionAdd = z.object({ kind: z.string(), text: 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 ---- // ---- helpers ----
function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void { 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); requireProjectAccess(app, req, id, true);
const { userId, perm } = shareAdd.parse(req.body); 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); 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 }; return { ok: true };
}); });
@@ -199,6 +197,36 @@ export function registerRoutes(app: FastifyInstance): void {
const { id, userId } = req.params as { id: string; userId: string }; const { id, userId } = req.params as { id: string; userId: string };
requireProjectAccess(app, req, id, true); requireProjectAccess(app, req, id, true);
db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId); 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 }; 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; const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) }; 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) })) };
});
} }
+51
View File
@@ -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<string>): Promise<void> {
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<void> { async function main(): Promise<void> {
const db = openDb(); const db = openDb();
// Ensure an admin owner exists (idempotent). // Ensure an admin owner exists (idempotent).
await ensureAdmin(db, hashPassword); 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 }; 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"); if (!owner) throw new Error("seed: no admin user to own the demo project");