# 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.