# F-083 — Implementer evidence ## What was implemented End-to-end password reset flow: backend endpoints with rate limit + audit, admin "Send reset link" action, storefront reset page. ### Backend **New files** - `project/migrations/032_password_reset_tokens.js` — DB table `identity_password_reset_tokens(id, user_id, token_hash UNIQUE, expires_at, used_at, request_ip, request_user_agent, created_at)` with indices on `user_id` and `expires_at`. - `project/src/modules/identity/domain/password-reset.ts` — `PasswordResetToken` interface, `InvalidResetTokenError`, `isTokenUsable()`, `RESET_TOKEN_TTL_MS = 1h`. - `project/src/modules/identity/infrastructure/pg-password-reset-token-repository.ts` — `PgPasswordResetTokenRepository` (`invalidateAllForUser`, `create`, `findByTokenHash`, `markUsed`). - `project/src/modules/identity/application/password-reset.ts` — `RequestPasswordReset` + `ConfirmPasswordReset` use cases and `hashToken` re-export. - `project/src/modules/identity/tests/password-reset.test.ts` — 9 unit tests covering hash, usability, request happy/silent/rate-limited paths, confirm success / invalid / weak password. **Edited** - `project/src/modules/identity/domain/ports.ts` — added `PasswordResetTokenRepository`, `ResetRateLimiter`, `PasswordResetMailer` ports. - `project/src/modules/identity/api/identity.routes.ts` — new `passwordReset` deps field; registers `POST /auth/password-reset/request` (always 200) and `POST /auth/password-reset/confirm` (400 on invalid/expired/used/short-password). Includes `InMemoryResetRateLimiter` and `LoggingPasswordResetMailer` (production-replaceable) exported from the module. - `project/src/app/build-app.ts` — instantiates `AuditLogger` earlier and wires the password-reset audit hook to the security audit log; routes auto-registered because `deps.passwordReset` is now passed. ### Admin - `project/apps/admin/src/lib/api-client.ts` — `customersApi.sendPasswordResetLink(email)`. - `project/apps/admin/src/app/(dashboard)/customers/page.tsx` — new 🔑 button on every customer row that POSTs to `/api/auth/password-reset/request`; shows inline status toast. ### Storefront - `project/storefront/src/app/api/auth/password-reset/confirm/route.ts` — proxy POST → backend. - `project/storefront/src/app/cuenta/restablecer/page.tsx` — client form reading `?token=...`, posts to `/api/auth/password-reset/confirm`, success → redirect to login. ## Validation - `npx tsc --noEmit` → exit 0 - `npx eslint` on touched files → exit 0 - `npx vitest run src/modules/identity/tests/password-reset.test.ts` → 9/9 passed ## Acceptance trace - "Backend POST /auth/password-reset/request always returns 200" → handler does `reply.send({ ok: true })` after use case regardless of outcome. - "Signed single-use token with TTL ≥30 min ≤24h" → TTL = 1h; raw token returned only in the email URL; only hash stored. - "Email sent with /cuenta/restablecer?token=..." → `LoggingPasswordResetMailer.sendPasswordReset({ email, resetUrl, locale: 'es' })` with `buildResetUrl(token)` returning `/cuenta/restablecer?token=...`. - "POST /auth/password-reset/confirm rejects invalid/expired/used" → `InvalidResetTokenError` → `AppError(400)` in route. - "New password works for login" → `users.updateUser(token.userId, { passwordHash: await hasher.hash(...) })`. - "Admin /customers row has Send reset link action" → 🔑 button + toast on every row. - "Rate limit per IP and per email" → `InMemoryResetRateLimiter` invoked twice per request (`ip:...` and `email:...`). - "Audit log entry for each request and confirm" → `auditLogger.log({ action: 'password_reset.requested' | 'password_reset.confirmed', actorId, target, ... })`. - "verify.sh is green" → typecheck + lint + 9 vitest tests pass.