Files
veridian/server/api/account/index.delete.ts
T
2026-10-08 13:08:47 -05:00

159 lines
4.9 KiB
TypeScript

import { and, eq, ne } 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;
// an instance always keeps one admin, someone has to be able to manage it
if (account?.role === 'admin') {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
.limit(1);
if (otherAdmins.length === 0) {
throw createError({
statusCode: 409,
statusMessage: 'The last admin cannot be deleted',
data: {
code: 'LAST_ADMIN',
},
});
}
}
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 };
});