Files
veridian/server/api/account/password.post.ts
T
2026-10-15 10:22:06 -05:00

83 lines
3.5 KiB
TypeScript

import { and, eq, sql } from "drizzle-orm";
import * as z from "zod";
import { providers, recoveryLinks } from "~~/drizzle/schema";
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
import { auth } from "~~/lib/auth";
import { authTransaction } from "~~/server/lib/auth-transaction";
export default defineEventHandler(async (event) => {
const { user } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
currentPassword: z.string().min(1).max(128),
newPassword: z.string().min(8).max(128),
providerKeys: z.array(z.object({
id: z.string(),
originalApiKey: z.string().min(1),
apiKey: z.string().min(1),
})),
}).safeParse(body));
if (!result.success) {
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
}
const { currentPassword, newPassword, providerKeys } = result.data;
const replacements = new Map(providerKeys.map(key => [key.id, key]));
let cookies: string[];
try {
cookies = await authTransaction(async (tx) => {
await lockAccount(tx, user.id);
const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
const withKey = existing.filter(provider => !!provider.config.apiKey);
const complete = replacements.size === providerKeys.length
&& replacements.size === withKey.length
&& withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
if (!complete) {
throw createError({
statusCode: 409,
statusMessage: "Provider keys out of date",
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
});
}
const { headers } = await auth.api.changePassword({
body: { currentPassword, newPassword, revokeOtherSessions: true },
headers: event.headers,
returnHeaders: true,
});
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
await revokeTwoFactorTrust(tx, user.id);
// Invalidate pending config writes even for providers without a key.
const updatedAt = new Date(Math.max(Date.now(), ...existing.map(provider => provider.updatedAt.getTime() + 1)));
await tx.update(providers).set({ updatedAt }).where(eq(providers.userId, user.id));
for (const key of providerKeys) {
await tx.update(providers).set({
config: sql`jsonb_set(${providers.config}, '{apiKey}', ${JSON.stringify(key.apiKey)}::jsonb)`,
updatedAt,
}).where(and(eq(providers.id, key.id), eq(providers.userId, user.id)));
}
return headers.getSetCookie();
});
} catch (error) {
if (error instanceof Error && "body" in error) {
const body = z.object({ code: z.string(), message: z.string().optional() }).safeParse(error.body);
if (body.success) {
throw createError({ statusCode: 400, message: body.data.message, data: { code: body.data.code } });
}
}
throw error;
}
// No replacement session reaches the browser until its transaction commits.
for (const cookie of cookies) {
appendResponseHeader(event, "set-cookie", cookie);
}
return { ok: true };
});