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
+34 -36
View File
@@ -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) })) };
});
}