# DESIGN — F-006 Users: profile, addresses, RBAC

## Boundary constraint that drives everything
Rule R1: a module may import only its own subtree, `src/shared`, Node builtins, and
npm packages. Therefore `users` MUST NOT import `identity`. The session authenticator
(which reads identity tables) is exported by identity's public API and INJECTED into
users by the composition root (`src/app/build-app.ts`). Shared contract types live in
`src/shared/auth.ts` so both modules agree without importing each other.

## Shared contract — src/shared/auth.ts
```ts
Role        = 'customer' | 'admin'
CurrentUser = { id, email, role }
Authenticate = (request: FastifyRequest) => Promise<CurrentUser>  // throws AppError 401
requireRole(user, role) -> void | throws AppError 403
```

## Role
Migration 003 (identity namespace): `ALTER TABLE identity_users ADD COLUMN role text
NOT NULL DEFAULT 'customer' CHECK (role IN ('customer','admin'))`. Role is the single
source of truth for "who may act as what" and travels with the authenticated user.

## identity additions
- `createSessionAuthenticator(pool): Authenticate` exported from identity index.
  Reads `mdv_session` cookie, hashes token, resolves via
  `identity_sessions JOIN identity_users` (valid, not revoked, not expired). Returns
  CurrentUser or throws AppError(401, 'UNAUTHORIZED').
- Login response now includes `role` (additive).

## users module layout (hexagonal)
```
src/modules/users/
  index.ts                    # registerUsersRoutes(app, { pool, authenticate })
  domain/
    profile.ts                # Profile type + normalize
    address.ts                # Address type
    errors.ts                 # ForbiddenError, NotFoundError
    ports.ts                  # ProfileRepository, AddressRepository
  application/
    get-profile.ts  update-profile.ts  list-profiles.ts
    list-addresses.ts  create-address.ts  update-address.ts  delete-address.ts
  infrastructure/
    pg-profile-repository.ts  pg-address-repository.ts
  api/users.routes.ts
  tests/
```

## Data model — migration 004_users
```sql
users_profiles(
  user_id uuid PK REFERENCES identity_users(id) ON DELETE CASCADE,
  display_name text, phone text,
  created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
)
users_addresses(
  id uuid PK DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
  label text, recipient_name text NOT NULL, street text NOT NULL,
  city text NOT NULL, postal_code text NOT NULL, country text NOT NULL,
  is_default boolean NOT NULL DEFAULT false,
  created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
) + index on user_id
```
FK to identity_users is schema-level integrity only; users runtime queries touch only
`users_*` tables (prefix rule upheld).

## API (all guarded; ownership = param id === auth id OR role admin)
| Route | Authz | Notes |
|---|---|---|
| GET /users | admin only | list profiles (AC2/AC3) |
| GET /users/:id | owner-or-admin | profile; non-owner -> 403 (AC1) |
| PATCH /users/:id | owner-or-admin | upsert display_name/phone |
| GET /users/:id/addresses | owner-or-admin | list |
| POST /users/:id/addresses | owner-or-admin | create |
| PATCH /users/:id/addresses/:addressId | owner-or-admin | update |
| DELETE /users/:id/addresses/:addressId | owner-or-admin | delete |

Authorization runs BEFORE existence checks so a non-owner gets 403 regardless of
whether the target exists (no enumeration). Bodies validated with parseJson (zod).

## Wiring (build-app)
```
if (deps.pool) {
  registerIdentityRoutes(...)
  const authenticate = createSessionAuthenticator(deps.pool)
  registerUsersRoutes(app, { pool, authenticate })
}
```

## Test strategy
- Unit (module-scoped): requireRole, ownership decision, address/profile normalization.
- Integration (app-scoped itest, real PostgreSQL): A->B 403; customer->admin-only 403;
  admin->admin-only 200; address CRUD end-to-end; unauthenticated -> 401; admin role
  granted by direct SQL UPDATE in the test.

## Risks / mitigations
- Cross-module sharing solved by injection + shared contract, not imports.
- Role escalation is impossible via API (role only set by DB/migration; register always
  creates 'customer').
