import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type LoginInput, type SafeUserProfile, type SignupInput, } from "@batch-cooking/shared"; import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; import { signAuthToken } from "../../lib/jwt.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; import { leaveCurrentHouse } from "../house/house.service.js"; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { /** The authenticated profile, safe to hand back to the client. */ profile: SafeUserProfile; /** Signed session JWT — the caller sets this as the session cookie's value. */ token: string; } // argon2's defaults (64 MB memory, 3 passes) are deliberately expensive — // that's the point, for real passwords. In tests we hash/verify dozens of // times per run against throwaway data, so a much cheaper cost keeps the // suite fast without weakening anything real. Never applies outside // NODE_ENV=test. const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; /** * Creates a profile (`user_profiles`), hashes the password, and issues a * session token. * * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. */ export async function signup(input: SignupInput): Promise { try { const existing = await prisma.userProfile.findUnique({ where: { email: input.email }, }); if (existing) { throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use"); } const passwordHash = await argon2.hash(input.password, hashOptions); // No household is created here — it's now an optional step of the // onboarding wizard (create or join one, or skip — see // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side // effect of signing up. `houseId` starts out `null`, same as `dietId`. const profile = await prisma.userProfile.create({ data: { firstName: input.firstName, lastName: input.lastName, email: input.email, passwordHash, }, }); const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion, }); return { profile: toSafeProfile(profile), token }; } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already // logs it, see `error-logger.ts`) is what actually handles it, this // service layer just isn't allowed a bare `await` per the repo's // async/try-catch convention. throw err; } } /** * Permanently deletes a profile, after re-verifying its password — * deleting an account is irreversible, so it's gated behind proving the * caller still is who the session says they are, same spirit as the * password check in {@link login}. * * If the profile administers a household with other members, adminship is * handed off before the profile is deleted (see `house.service.ts`'s * `leaveCurrentHouse`); if it's the household's last member, the household * itself is deleted along with it. `UserProfileAllergy` rows cascade via * the schema's `onDelete: Cascade`. * * @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong. */ export async function deleteAccount(profileId: number, password: string): Promise { try { const profile = await prisma.userProfile.findUnique({ where: { id: profileId }, }); if (!profile || !(await argon2.verify(profile.passwordHash, password))) { throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password"); } if (profile.houseId !== null) { await leaveCurrentHouse(profile.id, profile.houseId); } await prisma.userProfile.delete({ where: { id: profile.id } }); } catch (err) { throw err; // see signup()'s catch comment above } } /** * Verifies credentials and issues a fresh session token. * * @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email * or a wrong password — deliberately the same error either way, so a * caller can never learn whether a given email has an account. */ export async function login(input: LoginInput): Promise { try { const profile = await prisma.userProfile.findUnique({ where: { email: input.email }, }); if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) { throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); } const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion, }); return { profile: toSafeProfile(profile), token }; } catch (err) { throw err; // see signup()'s catch comment above } }