feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,99 @@
# 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').

View File

@@ -0,0 +1,31 @@
# SPEC — F-006 Users: profile, addresses, RBAC
## Problem
Authenticated users need profile data, addresses, and clear roles.
## Goal
Users module with profile + address CRUD behind use cases, and customer/admin roles
enforced by an RBAC guard on the API layer. The server decides ownership and role —
the frontend is never trusted.
## Scope IN
- `src/modules/users` (hexagonal) owning `users_profiles` + `users_addresses`
- Profile + address CRUD behind use cases
- Roles customer/admin; `role` column on `identity_users`
- Session authenticator exported by identity, injected into users by the composition root
- Owner-or-admin authorization; admin-only list endpoint
## Scope OUT
- No fine-grained permissions (only role + ownership)
- No admin UI
- No profile auto-creation at register (profiles are created on demand)
## Acceptance criteria
1. Given user A When A requests user B profile Then HTTP 403.
2. Given customer role When admin-only endpoint called Then HTTP 403.
3. Given admin role When admin-only endpoint called Then HTTP 200.
4. Address CRUD works end to end for own addresses.
5. `./scripts/verify.sh` green.
## Dependencies added
- None. Reuses pg, zod, @fastify/cookie.

View File

@@ -0,0 +1,14 @@
# TASKS — F-006 Users: profile, addresses, RBAC
- [ ] TASK-001 migrations: 003_identity_roles.js (role column) + 004_users.js (profiles + addresses), reversible
- [ ] TASK-002 shared/auth.ts: Role, CurrentUser, Authenticate, requireRole
- [ ] TASK-003 identity: session-authenticator (cookie -> CurrentUser) + export from index + role in login response
- [ ] TASK-004 users domain: profile.ts, address.ts, errors.ts, ports.ts
- [ ] TASK-005 users application: profile get/update/list + address list/create/update/delete (owner-or-admin checks)
- [ ] TASK-006 users infrastructure: pg repos (parameterized)
- [ ] TASK-007 users api: users.routes.ts (parseJson, error mapping)
- [ ] TASK-008 users index.ts public API
- [ ] TASK-009 build-app wiring: authenticate injection
- [ ] TASK-010 unit tests (module-scoped)
- [ ] TASK-011 integration tests (app-scoped itest, AC1-AC4)
- [ ] TASK-012 lint + boundaries + typecheck + build + test + test:integration + verify.sh

View File

@@ -0,0 +1,20 @@
# TESTS — F-006 Users: profile, addresses, RBAC
## Unit — src/shared/tests/auth.test.ts
1. requireRole allows matching role, throws 403 AppError otherwise
## Unit — src/modules/users/tests/ownership.test.ts
1. canAccessUser: owner true, admin true, other customer false
2. same decisions regardless of target existence (authz before lookup)
## Integration — src/app/tests/users.itest.ts (real PostgreSQL)
1. unauthenticated GET /users/:id -> 401 UNAUTHORIZED
2. register+login A and B; A GET /users/:idB -> 403 FORBIDDEN (AC1); A GET own -> 200
3. PATCH own profile upserts; GET returns it
4. GET /users as customer -> 403 (AC2)
5. promote A to admin via SQL; GET /users -> 200 with items (AC3)
6. address CRUD end-to-end for own addresses: POST 201, GET list, PATCH 200, DELETE 204 (AC4)
7. A cannot POST/PATCH/DELETE addresses on B's resource -> 403
8. PATCH/DELETE unknown address id on own resource -> 404
9. invalid address uuid param -> 400 VALIDATION_ERROR
10. F-005 regression: login still 200 + cookie; /auth routes intact