# 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 // 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').