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:
@@ -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);
|
||||
|
||||
183
src/server/routes/account.ts
Normal file
183
src/server/routes/account.ts
Normal 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;
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user