Files
mercadodevida/project
rikrdo 546971280f feat(F-006): users profile, addresses and RBAC
- 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
2026-08-15 09:28:15 +02:00
..

MercadoDeVida backend — modular monolith skeleton

TypeScript + Fastify modular monolith. Simple code, clear modules, small changes, no magic.

Requirements

  • Node.js >= 22
  • npm

Commands

npm install           # install dependencies
npm run build         # compile to dist/
npm start             # run compiled server (PORT, HOST env vars)
npm test              # vitest unit tests (no database needed)
npm run typecheck     # tsc --noEmit
npm run lint          # eslint + prettier check
npm run lint:boundaries  # module boundary check

Configuration

Startup is fail-fast: src/infrastructure/config parses env once and refuses to boot on missing/invalid required vars. DATABASE_URL is required; PORT, HOST, LOG_LEVEL, NODE_ENV, REDIS_URL are optional with defaults. COOKIE_SECURE defaults to true (set false only for local http dev). Errors name variable NAMES only, never values.

Feature flags: FLAG_<NAME>=true|false env vars seed the flag store at boot. Unknown flags default to OFF (fail-safe). Flags flip at runtime through the store — activation is separate from deployment (no redeploy). Copy .env.example to .env to start.

HTTP contract

  • Every response carries an x-request-id header (propagated from a safe incoming x-request-id, or a fresh UUID). Every JSON log line for a request carries the same id.
  • Errors always use one envelope: { "error": { "statusCode", "code", "message", "details?" }, "requestId" } Codes: NOT_FOUND, VALIDATION_ERROR, BAD_REQUEST/Fastify 4xx codes, INTERNAL_ERROR. 5xx messages are always generic; stack traces stay in server logs only.
  • Input validation is explicit per route: parseJson(schema, body) (zod) in the handler.
  • Auth codes: UNAUTHORIZED (401, missing/invalid/revoked session), FORBIDDEN (403, role or ownership check failed), INVALID_CREDENTIALS (401), EMAIL_ALREADY_REGISTERED (409), TOO_MANY_ATTEMPTS (429, with Retry-After header).
  • Log level via LOG_LEVEL env var (default info); logs are JSON only.

Authentication (identity module)

The server is the only authority for identity; the frontend is never trusted with session or credential state.

Route Result
POST /auth/register 201 + { id, email, role, createdAt }
POST /auth/login 200 + { id, email, role } + Set-Cookie: mdv_session
POST /auth/logout 204, cookie cleared, session revoked (idempotent)
  • Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never plaintext or anything reversible.
  • Sessions: opaque 512-bit token in the cookie; the DB stores only its SHA-256 hash (identity_sessions.token_hash). TTL 7 days; logout revokes server-side.
  • Cookie: HttpOnly, Secure (COOKIE_SECURE, default true), SameSite=Lax, Path=/, Max-Age=604800.
  • Login failures: identical generic 401 for unknown email and wrong password (no enumeration; timing equalized via dummy hash). After 10 consecutive failures per email, further attempts get 429 with Retry-After for 15 minutes. The limiter is in-memory per instance behind a LoginRateLimiter interface (Redis-backed swap later without touching use cases).
  • Identity routes are wired only when the app is built with a DB pool.

Users and RBAC (users module)

Every route resolves the session cookie against the DB first (expired/revoked sessions and missing cookies get 401 UNAUTHORIZED). Roles are customer (default) and admin; the role is read from identity_users on every request, so promotions/demotions apply immediately. Role changes are an out-of-band DB operation in this slice (no admin API yet).

Route Access Result
GET /users admin only 200 + { items: [profile] }
GET /users/:id owner or admin 200 profile, 404 if none yet
PATCH /users/:id owner or admin 200 upserted profile
GET /users/:id/addresses owner or admin 200 + { items: [address] }
POST /users/:id/addresses owner or admin 201 address
PATCH /users/:id/addresses/:addressId owner or admin 200 address, 404 if not theirs
DELETE /users/:id/addresses/:addressId owner or admin 204, 404 if not theirs
  • Authorization is checked before existence: a non-owner gets 403 FORBIDDEN regardless of whether the target resource exists (no enumeration).
  • Address queries are scoped by user_id in SQL, so a valid foreign address id is unreachable.
  • GET /users lists users that have a profile row (users who have patched their profile at least once).
  • The users module never imports identity: session resolution arrives as an injected Authenticate function from the composition root.

Database (local dev)

cp .env.example .env  # once
npm run docker:up     # start PostgreSQL 16 + Redis 7
npm run db:up         # apply migrations
npm run db:status     # list applied migrations
npm run db:down       # revert last migration
npm run test:integration  # integration tests (need TEST_DATABASE_URL from .env)
npm run docker:down   # stop services (add -v to wipe volumes)

Table naming convention

<module>_<table>          e.g. catalog_products, inventory_stock, orders_orders
  • Every table is prefixed with its owning module.
  • A module never queries tables without its own prefix; data flows through module interfaces.
  • Migrations are immutable once merged: fixes ship as new migrations.
  • No schema change without migration.

Layout

src/
├── app/             # composition root (only place that wires modules)
├── infrastructure/  # http server, db pool, config, logging
├── modules/         # business modules, one folder each
│   ├── health/      # exemplar module: public API only via index.ts
│   ├── flags/       # feature flags (unknown default OFF, runtime flip)
│   ├── identity/    # register/login/logout, argon2, sessions, rate limit
│   └── users/       # profile + address CRUD, owner-or-admin RBAC
└── shared/          # cross-cutting helpers (error envelope, input parsing)

Module rules

  • A module exposes its public API only through its index.ts.
  • Files inside a module may import: own subtree, src/shared, Node builtins, npm packages.
  • Code outside modules (app/infrastructure) may import a module only via its index.ts.
  • npm run lint:boundaries enforces these rules.