- users module: profile + address CRUD behind use cases (users_profiles, users_addresses) - roles customer/admin on identity_users; role resolved from DB per request - shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin) injected from composition root; users never imports identity - authorization runs before existence checks; address SQL scoped by user_id - @fastify/cookie registered once at app root (cross-module) - migrations 003_identity_roles + 004_users (reversible) - no new npm dependencies; tests: unit 52, integration 22 Gates: reviewer/security/qa APPROVED; verify.sh green
4.6 KiB
4.6 KiB
done -> work/artifacts/F-006/implementer.md
F-006 — Users: profile, addresses, RBAC (Build)
Agent: implementer Stage: build Feature: F-006
Scope delivered
Profile + address management with owner-or-admin authorization, on top of the F-005 identity foundation. No new npm dependencies were added.
Files
Migrations (immutable; new migrations only)
project/migrations/003_identity_roles.js— addsidentity_users.role(customer|admin, defaultcustomer, NOT NULL) + index.project/migrations/004_users.js—users_profiles(PKuser_idFK →identity_users,display_name,phone, timestamps) andusers_addresses(iduuid PK,user_idFK ON DELETE CASCADE, address fields,is_default, timestamps,user_idindex).
Shared auth contract
project/src/shared/auth.ts—Role,CurrentUser,Authenticate,requireRole,requireOwnerOrAdmin. Shared (not module-owned) so both identity and users use one authorization vocabulary without cross-module imports.
Identity additions
project/src/modules/identity/infrastructure/session-authenticator.ts— resolves the session cookie intoCurrentUserfrom the DB (expiry + revocation enforced in SQL). Exported through the module index so the composition root can inject it.- Role added to
User,PgUserRepository(SELECT/RETURNINGrole), and the register/login responses.
Users module (hexagonal)
domain/profile.ts,domain/address.ts,domain/ports.tsapplication/profile-use-cases.ts,application/address-use-cases.tsinfrastructure/pg-profile-repository.ts,infrastructure/pg-address-repository.tsapi/users.routes.ts— registers the endpoints below.index.ts— public API.
Composition root
project/src/app/build-app.ts— registers@fastify/cookieonce at root (cross-module infrastructure), wires identity routes, builds theAuthenticatefrom identity and injects it into the users routes.
Tests
project/src/shared/tests/auth.test.ts(unit)project/src/app/tests/users.itest.ts(integration)project/src/infrastructure/db/tests/migrations.itest.ts— full rollback now assertsusers_addresses,users_profiles, identity and baseline all drop.
Endpoints
| Method | Path | Auth |
|---|---|---|
| GET | /users | admin only |
| GET | /users/:id | owner or admin |
| PATCH | /users/:id | owner or admin |
| GET | /users/:id/addresses | owner or admin |
| POST | /users/:id/addresses | owner or admin |
| PATCH | /users/:id/addresses/:addressId | owner or admin |
| DELETE | /users/:id/addresses/:addressId | owner or admin |
Authorization runs BEFORE existence checks: a non-owner gets 403 regardless of
whether the target resource exists. Address operations are additionally scoped
by user_id in SQL, so a caller cannot touch another user's address even with
a valid id.
Verification
npm run lint,npm run lint:boundaries,npm run typecheck,npm run build: clean.npm test: 52 passed, 22 skipped (integration skipped without TEST_DATABASE_URL).npm run test:integration: 22 passed against real PostgreSQL 16../scripts/verify.sh: green.- Live smoke (
PORT=3995): register/login 201/200; PATCH own profile 200; GET own 200; cross-user GET 403; GET /users as customer 403; after promotion GET /users as admin 200; address CRUD 201/204; cross-user address 403; no-cookie GET 401. Smoke data removed afterwards.
Deviations from TESTS.md (documented, not silent)
- The planned
users/tests/ownership.test.tswas merged intoshared/tests/auth.test.ts, because the owner-or-admin decision lives in the shared contract (shared/auth.ts), not in the users module. Coverage is equivalent. @fastify/cookieregistration moved from identity's route scope to the app root. Fastify encapsulation meant therequest.cookiesdecorator was not visible in the users scope (caused 500s); cookies are now cross-module infrastructure registered once at the composition root.
Notes for gates
- Reviewer: check the SQL column whitelist in
pg-profile-repository.upsertandpg-address-repository.update(only hardcoded columns; values always parameterized), and that every users route callsauthenticatethenrequireOwnerOrAdminbefore touching data. - Security: session resolution is DB-backed (revocation + expiry in SQL); role
is read from
identity_usersper request, so promotion/demotion is reflected immediately without trusting the client. - QA: integration suite exercises every acceptance criterion (AC1–AC4) plus the F-005 logout regression.