Files
mercadodevida/work/artifacts/F-083/implementer.md
2026-08-20 06:08:32 +02:00

3.7 KiB

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.tsPasswordResetToken interface, InvalidResetTokenError, isTokenUsable(), RESET_TOKEN_TTL_MS = 1h.
  • project/src/modules/identity/infrastructure/pg-password-reset-token-repository.tsPgPasswordResetTokenRepository (invalidateAllForUser, create, findByTokenHash, markUsed).
  • project/src/modules/identity/application/password-reset.tsRequestPasswordReset + 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.tscustomersApi.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" → InvalidResetTokenErrorAppError(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.