import { eq } from 'drizzle-orm'; import * as z from 'zod'; import { agents, files, models, providers, settings, toolCalls, topics, users, } from '~~/drizzle/schema'; import { auth } from '~~/lib/auth'; import { db } from '~~/server/lib/db'; /** * Deletes the signed in user's account and everything that belongs to it. * * The app tables only store a plain user id, nothing cascades from the users * table, so every one of them is cleared here. It all happens in one * transaction: either the whole account is gone or nothing changed. * * Objects in file storage are left alone on purpose. */ export default defineEventHandler(async (event) => { await protectRoute(event); const userId = event.context.user!.id as string; const result = await readValidatedBody(event, (body) => z .object({ password: z.string().min(1), // required when two-factor is on: a code from the authenticator // app, or one of the backup codes code: z.string().min(1).optional(), backupCode: z.boolean().optional(), }) .safeParse(body), ); if (!result.success) { throw createError({ statusCode: 400, message: result.error.issues[0]!.message, }); } const { password, code, backupCode } = result.data; // read from the database, the session can lag behind by a few minutes const account = await db.query.users.findFirst({ where: { id: userId, }, }); const twoFactorEnabled = !!account?.twoFactorEnabled; if (twoFactorEnabled && code === undefined) { throw createError({ statusCode: 400, statusMessage: 'Two-factor code required', data: { code: 'TWO_FACTOR_REQUIRED', }, }); } try { await auth.api.verifyPassword({ body: { password, }, headers: event.headers, }); if (twoFactorEnabled && backupCode) { await auth.api.verifyBackupCode({ body: { code: code!, }, headers: event.headers, }); } else if (twoFactorEnabled) { await auth.api.verifyTOTP({ body: { code: code!, }, headers: event.headers, }); } } catch (error: any) { const errorCode = error?.body?.code as string | undefined; if (errorCode === undefined) { console.error('Failed to verify credentials:', error); } throw createError({ statusCode: errorCode === undefined ? 500 : 400, statusMessage: 'Failed to delete account', data: { code: errorCode ?? 'INTERNAL_ERROR', }, }); } await db.transaction(async (tx) => { // messages, their parts, generations and attachments cascade from topics await tx.delete(topics).where(eq(topics.userId, userId)); await tx.delete(agents).where(eq(agents.userId, userId)); await tx.delete(models).where(eq(models.userId, userId)); await tx.delete(providers).where(eq(providers.userId, userId)); // nothing points a tool call back at its message, so these never cascade await tx.delete(toolCalls).where(eq(toolCalls.userId, userId)); // embeddings go with their file await tx.delete(files).where(eq(files.userId, userId)); await tx.delete(settings).where(eq(settings.userId, userId)); // sessions, accounts and two-factor rows cascade from the user await tx.delete(users).where(eq(users.id, userId)); }); // the session is gone, but the browser would keep trusting its cached // copy for a few minutes unless the cookies go too const { authCookies } = await auth.$context; for (const cookie of Object.values(authCookies)) { deleteCookie(event, cookie.name, { path: cookie.attributes.path, secure: cookie.attributes.secure, sameSite: cookie.attributes.sameSite, httpOnly: cookie.attributes.httpOnly, }); } return { ok: true }; });