import * as bcrypt from 'bcryptjs'; /** * Value written into `passwordHash` for accounts that must never log in with a * password — the ~1,590 legacy customers who only ever used OTP * (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails * "unusable password" convention: readable by anyone looking at the row, and * matched by no password. * * Kept in this dependency-free module (bcryptjs only) so the data-import * script can use it without loading NestJS. */ export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN'; /** * A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from * the legacy server — all three verify with bcryptjs). Anything else is not a * password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH. * * Such hashes are refused here, BEFORE bcrypt is called, so the outcome never * depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3 * happens to return false today; a future version or a native binding might * throw and turn a wrong password into a 500 (owner ruling 2026-10-07). */ export async function compareLocalHash(password: string, passwordHash: string | null | undefined) { if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false; return bcrypt.compare(password, passwordHash); } /** True when a stored legacy hash can be carried over as a working password. */ export function isPasswordHash(hash: unknown): hash is string { return typeof hash === 'string' && hash.startsWith('$2'); }