seahaven-ap/packages/api/src/auth/upsert-user.ts

92 lines
2 KiB
TypeScript
Raw Normal View History

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<AuthUser> {
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);
}