feat(28-01): add account management routes for password, email, and deletion

Creates /api/account routes with password change (verifies current first),
email update, has-password check, and account deletion with public setup
anonymization. Adds Zod validation schemas and registers routes in index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 17:47:17 +02:00
parent fcd8279d79
commit e8207a33f9
5 changed files with 220 additions and 17 deletions

View File

@@ -0,0 +1,183 @@
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<Env>();
// All account management routes require authentication (per T-28-05)
app.use("*", requireAuth);
// ── Helper ──────────────────────────────────────────────────────────
async function getLogtoSub(db: Db, userId: number): Promise<string> {
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 });
});
// ── 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.isPublic, true)));
// 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;