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

@@ -11,6 +11,7 @@ import { seedDefaults } from "../db/seed.ts";
import { mcpRoutes } from "./mcp/index.ts";
import { requireAuth } from "./middleware/auth.ts";
import { createRateLimit } from "./middleware/rateLimit.ts";
import { accountRoutes } from "./routes/account.ts";
import { authRoutes } from "./routes/auth.ts";
import { categoryRoutes } from "./routes/categories.ts";
import { discoveryRoutes } from "./routes/discovery.ts";
@@ -178,6 +179,7 @@ app.use("/api/*", async (c, next) => {
});
// API routes
app.route("/api/account", accountRoutes);
app.route("/api/auth", authRoutes);
app.route("/api/items", itemRoutes);
app.route("/api/categories", categoryRoutes);

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;

View File

@@ -127,3 +127,17 @@ export const updateProfileSchema = z.object({
avatarUrl: z.string().optional(),
bio: z.string().max(500).optional(),
});
// Account management schemas (per D-05)
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
export const changeEmailSchema = z.object({
newEmail: z.string().email(),
});
export const deleteAccountSchema = z.object({
confirmation: z.literal("DELETE"),
});

View File

@@ -12,11 +12,14 @@ import type {
} from "../db/schema.ts";
import type {
bulkUpsertGlobalItemsSchema,
changeEmailSchema,
changePasswordSchema,
createCandidateSchema,
createCategorySchema,
createItemSchema,
createSetupSchema,
createThreadSchema,
deleteAccountSchema,
reorderCandidatesSchema,
resolveThreadSchema,
searchGlobalItemsSchema,
@@ -67,3 +70,8 @@ export type SetupItem = typeof setupItems.$inferSelect;
export type GlobalItem = typeof globalItems.$inferSelect;
export type Tag = typeof tags.$inferSelect;
export type GlobalItemTag = typeof globalItemTags.$inferSelect;
// Account management types
export type ChangePassword = z.infer<typeof changePasswordSchema>;
export type ChangeEmail = z.infer<typeof changeEmailSchema>;
export type DeleteAccount = z.infer<typeof deleteAccountSchema>;

View File

@@ -28,7 +28,11 @@ describe("LogtoManagementClient", () => {
}
// GET user
if (url.includes("/api/users/") && options.method === "GET" && !url.includes("has-password")) {
if (
url.includes("/api/users/") &&
options.method === "GET" &&
!url.includes("has-password")
) {
return Promise.resolve(
new Response(
JSON.stringify({
@@ -112,9 +116,7 @@ describe("LogtoManagementClient", () => {
await client.getAccessToken();
// Only one call to token endpoint — second call uses cache
const tokenCalls = fetchCalls.filter((c) =>
c.url.includes("/oidc/token"),
);
const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
expect(tokenCalls.length).toBe(1);
});
@@ -128,9 +130,7 @@ describe("LogtoManagementClient", () => {
await client.getAccessToken();
const tokenCalls = fetchCalls.filter((c) =>
c.url.includes("/oidc/token"),
);
const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token"));
expect(tokenCalls.length).toBe(2);
});
@@ -147,7 +147,7 @@ describe("LogtoManagementClient", () => {
test("verifyPassword returns false on 422", async () => {
// Override fetch for this specific test
globalThis.fetch = mock((url: string, options: RequestInit) => {
globalThis.fetch = mock((url: string, _options: RequestInit) => {
if (url.includes("/oidc/token")) {
return Promise.resolve(
new Response(
@@ -218,8 +218,7 @@ describe("LogtoManagementClient", () => {
const deleteCall = fetchCalls.find(
(c) =>
c.url.includes("/api/users/sub-123") &&
c.options.method === "DELETE",
c.url.includes("/api/users/sub-123") && c.options.method === "DELETE",
);
expect(deleteCall).toBeDefined();
});
@@ -251,16 +250,13 @@ describe("LogtoManagementClient", () => {
// Token call should go to https://logto.example.com/oidc/token
const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
expect(tokenCall!.url).toBe(
"https://logto.example.com/oidc/token",
);
expect(tokenCall!.url).toBe("https://logto.example.com/oidc/token");
// API call should go to https://logto.example.com/api/users/test-sub
const apiCall = fetchCalls.find(
(c) => c.url.includes("/api/users/test-sub") && c.options.method === "GET",
);
expect(apiCall!.url).toBe(
"https://logto.example.com/api/users/test-sub",
(c) =>
c.url.includes("/api/users/test-sub") && c.options.method === "GET",
);
expect(apiCall!.url).toBe("https://logto.example.com/api/users/test-sub");
});
});