# DESIGN — F-005 Identity: register, login, sessions

## Module layout (hexagonal, screaming)
```
src/modules/identity/
  index.ts                          # public API only: registerIdentityRoutes + types
  domain/
    user.ts                         # User, NewUser types; email normalization
    session.ts                      # Session type, token TTL constants
    errors.ts                       # InvalidCredentials, EmailAlreadyRegistered, RateLimited
    ports.ts                        # PasswordHasher, UserRepository, SessionRepository
  application/
    register-user.ts                # RegisterUser use case
    login.ts                        # Login use case (rate limit + timing equalization)
    logout.ts                       # Logout use case (revoke by token hash)
    login-rate-limiter.ts           # LoginRateLimiter interface + InMemoryLoginRateLimiter
  infrastructure/
    argon2-password-hasher.ts       # argon2id, OWASP params
    pg-user-repository.ts           # identity_users CRUD (pg parameterized)
    pg-session-repository.ts        # identity_sessions create/revoke-by-token-hash
    session-token.ts                # random token + sha256 hash helpers
  api/
    identity.routes.ts              # POST /auth/register | /auth/login | /auth/logout
  tests/
    login-rate-limiter.test.ts      # unit
    session-token.test.ts           # unit
```

Rules respected: domain imports nothing from infra/frameworks; application depends on
domain ports only; infrastructure implements ports; api is a thin adapter using
`parseJson` (F-003) and maps domain errors to AppError.

## Data model — migration 002_identity
```sql
identity_users(
  id uuid PK DEFAULT gen_random_uuid(),
  email citext NOT NULL UNIQUE,
  password_hash text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
)
identity_sessions(
  id uuid PK DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,     -- sha256 of the opaque cookie token
  created_at timestamptz NOT NULL DEFAULT now(),
  expires_at timestamptz NOT NULL,
  revoked_at timestamptz
) + index on user_id
```
- citext for case-insensitive unique email (extension from baseline).
- Only the token HASH is stored: a DB leak does not yield usable sessions.
- Down drops both tables (reverse FK order).

## Password hashing
- argon2id, memoryCost 19456 KiB, timeCost 2, parallelism 1 (OWASP 2024 baseline).
- Output PHC string (`$argon2id$...`) stored as-is; verify via argon2.verify.
- Timing equalization: unknown email still runs one verify against a module-level
  dummy hash so 401 timing does not enumerate users.

## Sessions and cookie
- Token: `crypto.randomBytes(64).toString('base64url')` (opaque, 512 bits).
- TTL: 7 days, stamped in `expires_at`; expired/revoked sessions are invalid.
- Cookie `mdv_session`: HttpOnly, SameSite=Lax, Path=/, Secure per config
  (`COOKIE_SECURE`, default true; `.env.example` sets false for local http dev),
  Max-Age = TTL.
- Logout revokes by token hash (`revoked_at`), always answers 204 and clears the
  cookie (idempotent).

## Rate limiting
```ts
interface LoginRateLimiter {
  consume(key: string): { allowed: true } | { allowed: false; retryAfterMs: number };
  recordFailure(key: string): void;
  reset(key: string): void;
}
```
- InMemoryLoginRateLimiter: per normalized email, threshold 10 consecutive failures,
  15-minute cooldown once reached, reset on success. Lazy sweep of expired entries.
- In-memory per instance (documented limitation; interface allows Redis-backed swap).
- 429 response includes `Retry-After` seconds.

## Endpoints
| Route | Success | Errors |
|---|---|---|
| POST /auth/register | 201 `{ id, email, createdAt }` | 400 VALIDATION_ERROR, 409 EMAIL_ALREADY_REGISTERED |
| POST /auth/login | 200 `{ id, email }` + Set-Cookie | 401 INVALID_CREDENTIALS (generic), 429 TOO_MANY_ATTEMPTS |
| POST /auth/logout | 204 + clear cookie | — (idempotent) |

Request bodies validated with zod via `parseJson` (F-003 hook): email + password
(min 8, max 128 chars).

## Wiring
- `pool.ts`: `createPool(connectionString)` replaces `createPoolFromEnv` (config owns env).
- `config.ts`: adds optional `COOKIE_SECURE` (default true).
- `buildApp(deps)`: new optional `pool` + `cookieSecure`; when pool present it registers
  identity routes. No pool = foundation-only app (keeps F-001..F-004 tests DB-free).
- `server.ts`: creates pool from config, passes into buildApp.

## Test strategy
- Unit (module-scoped): rate limiter thresholds/reset/cooldown; session token
  randomness/hashing; domain error mapping.
- Composition (app-scoped itest, real PostgreSQL): register/login/logout happy paths;
  argon2 PHC hash in DB + no plaintext; duplicate email 409; identical 401 bodies for
  wrong password vs unknown email; cookie attributes; 10 failures -> 429 + Retry-After;
  logout revokes (second logout idempotent, session unusable).

## Risks / mitigations
- Native argon2 build: toolchain present; if install fails, switch impl behind the
  PasswordHasher port to @node-rs/argon2 (no design change).
- Enumeration: generic 401 + dummy verify; register 409 accepted as standard contract
  (login is the enumeration-sensitive path per acceptance).
- In-memory rate limiter bypass via multi-instance: documented; interface ready for Redis.
