import { Inject, Injectable, Logger } from '@nestjs/common'; import { ENVIRONMENT } from '../config/environment.module'; import type { Environment } from '../config/environment'; import { AuthStore } from './auth.store'; import { RecoveryStore } from './recovery.store'; import { RecoveryMailer } from './recovery-mailer'; import { RateLimitService } from './rate-limit.service'; import { PasswordService } from './password.service'; import { hashToken, issueToken } from './tokens'; import type { RecoveryInput } from './identity.schemas'; @Injectable() export class RecoveryService { private readonly logger = new Logger(RecoveryService.name); constructor( private readonly users: AuthStore, private readonly store: RecoveryStore, private readonly mailer: RecoveryMailer, private readonly limits: RateLimitService, private readonly passwords: PasswordService, @Inject(ENVIRONMENT) private readonly env: Environment, ) {} async request(input: RecoveryInput): Promise { this.mailer.assertConfigured(); await this.limits.consume( `recovery:${input.organizationId}:${input.email}`, 3, 900, ); const user = await this.users.findUser(input.organizationId, input.email); if (!user || user.status !== 'ACTIVE') return; const { token, tokenHash } = issueToken(); const created = await this.store.create( user.id, tokenHash, new Date(Date.now() + this.env.RECOVERY_TTL_MINUTES * 60_000), ); if (!created) return; try { await this.mailer.send(user.email, token); } catch { await this.store.discard(tokenHash); // SMTP errors may contain credentials and addresses; do not log the raw error. this.logger.error( JSON.stringify({ event: 'RECOVERY_DELIVERY_FAILED', message: 'Password recovery delivery failed', }), ); } } async reset(token: string, password: string): Promise { await this.store.reset( hashToken(token), await this.passwords.hash(password), ); } }