import { zValidator } from "@hono/zod-validator"; import { and, eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import type { db as prodDb } from "../../db/index.ts"; import { apiKeys, categories, items, oauthCodes, oauthTokens, settings, setupItems, setups, threadCandidates, threads, users, } from "../../db/schema.ts"; import { changeEmailSchema, changePasswordSchema, deleteAccountSchema, } from "../../shared/schemas.ts"; import { requireAuth } from "../middleware/auth.ts"; import { logtoClient } from "../services/logto.service.ts"; type Db = typeof prodDb; type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); // All account management routes require authentication (per T-28-05) app.use("*", requireAuth); // ── Helper ────────────────────────────────────────────────────────── async function getLogtoSub(db: Db, userId: number): Promise { const [user] = await db .select({ logtoSub: users.logtoSub }) .from(users) .where(eq(users.id, userId)); if (!user) throw new Error("User not found"); return user.logtoSub; } // ── Change Password (D-05) ────────────────────────────────────────── app.post("/password", zValidator("json", changePasswordSchema), async (c) => { const db = c.get("db") as Db; const userId = c.get("userId")!; const { currentPassword, newPassword } = c.req.valid("json"); const logtoSub = await getLogtoSub(db, userId); // Per T-28-03: ALWAYS verify current password before setting new one const isValid = await logtoClient.verifyPassword(logtoSub, currentPassword); if (!isValid) { return c.json({ error: "Current password is incorrect" }, 400); } await logtoClient.updatePassword(logtoSub, newPassword); return c.json({ ok: true }); }); // ── Change Email (D-05) ───────────────────────────────────────────── app.post("/email", zValidator("json", changeEmailSchema), async (c) => { const db = c.get("db") as Db; const userId = c.get("userId")!; const { newEmail } = c.req.valid("json"); const logtoSub = await getLogtoSub(db, userId); await logtoClient.updateEmail(logtoSub, newEmail); return c.json({ ok: true, email: newEmail }); }); // ── Has Password ──────────────────────────────────────────────────── app.get("/has-password", async (c) => { const db = c.get("db") as Db; const userId = c.get("userId")!; const logtoSub = await getLogtoSub(db, userId); const hasPassword = await logtoClient.hasPassword(logtoSub); return c.json({ hasPassword }); }); // ── Delete Account (D-05, D-06) ───────────────────────────────────── app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => { const db = c.get("db") as Db; const userId = c.get("userId")!; const logtoSub = await getLogtoSub(db, userId); // Per T-28-06: Run deletion in a single transaction await db.transaction(async (tx) => { // 1. Get or create sentinel "Deleted User" for public setup attribution (D-06) let [sentinel] = await tx .select({ id: users.id }) .from(users) .where(eq(users.logtoSub, "deleted-user")); if (!sentinel) { [sentinel] = await tx .insert(users) .values({ logtoSub: "deleted-user", displayName: "Deleted User", }) .returning({ id: users.id }); } // 2. Reassign public setups to sentinel user await tx .update(setups) .set({ userId: sentinel.id }) .where(and(eq(setups.userId, userId), eq(setups.visibility, "public"))); // 3. Get private setup IDs for cleanup const privateSetups = await tx .select({ id: setups.id }) .from(setups) .where(eq(setups.userId, userId)); if (privateSetups.length > 0) { const privateSetupIds = privateSetups.map((s) => s.id); // Delete setup items for private setups await tx .delete(setupItems) .where(inArray(setupItems.setupId, privateSetupIds)); // Delete private setups await tx.delete(setups).where(eq(setups.userId, userId)); } // 4. Get thread IDs for candidate cleanup const userThreads = await tx .select({ id: threads.id }) .from(threads) .where(eq(threads.userId, userId)); if (userThreads.length > 0) { const threadIds = userThreads.map((t) => t.id); // Delete thread candidates (cascade should handle this, but be explicit) await tx .delete(threadCandidates) .where(inArray(threadCandidates.threadId, threadIds)); } // 5. Delete threads await tx.delete(threads).where(eq(threads.userId, userId)); // 6. Delete items await tx.delete(items).where(eq(items.userId, userId)); // 7. Delete categories await tx.delete(categories).where(eq(categories.userId, userId)); // 8. Delete API keys await tx.delete(apiKeys).where(eq(apiKeys.userId, userId)); // 9. Delete settings await tx.delete(settings).where(eq(settings.userId, userId)); // 10. Delete OAuth codes and tokens await tx.delete(oauthCodes).where(eq(oauthCodes.userId, userId)); await tx.delete(oauthTokens).where(eq(oauthTokens.userId, userId)); // 11. Delete user record await tx.delete(users).where(eq(users.id, userId)); }); // Delete user from Logto (outside transaction — external service) try { await logtoClient.deleteUser(logtoSub); } catch (err) { console.error("[account] Failed to delete Logto user:", err); // Don't fail the request — local data is already cleaned up } return c.json({ ok: true, redirectTo: "/logout" }); }); export const accountRoutes = app;