import { eq } from "drizzle-orm"; import type { Db } from "../db/client.js"; import { users } from "../db/schema/index.js"; import type { UserRole } from "../env.js"; export type AuthIdentity = { cognitoSub: string; email: string; name: string; role: UserRole; }; export type AuthUser = { id: string; cognitoSub: string; email: string; name: string; role: UserRole; }; export class IdentityConflictError extends Error { readonly status = 409 as const; constructor(readonly email: string) { super(`Email ${email} is already linked to a different identity.`); this.name = "IdentityConflictError"; } } function toAuthUser(row: { id: string; cognitoSub: string; email: string; name: string; role: UserRole; }): AuthUser { return { id: row.id, cognitoSub: row.cognitoSub, email: row.email, name: row.name, role: row.role, }; } /** * Upsert by Cognito subject only. Never rebind an existing email to a new * subject — that would allow account takeover if email claims collide. */ export async function upsertUserFromIdentity( handle: Db, identity: AuthIdentity, ): Promise { const bySub = await handle.db.query.users.findFirst({ where: eq(users.cognitoSub, identity.cognitoSub), }); if (bySub) { const [updated] = await handle.db .update(users) .set({ email: identity.email, name: identity.name, role: identity.role, updatedAt: new Date(), }) .where(eq(users.id, bySub.id)) .returning(); return toAuthUser(updated); } const byEmail = await handle.db.query.users.findFirst({ where: eq(users.email, identity.email), }); if (byEmail) { throw new IdentityConflictError(identity.email); } const [created] = await handle.db .insert(users) .values({ cognitoSub: identity.cognitoSub, email: identity.email, name: identity.name, role: identity.role, }) .returning(); return toAuthUser(created); }