- 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
6.8 KiB
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-idheader (propagated from a safe incomingx-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, withRetry-Afterheader). - Log level via
LOG_LEVELenv var (defaultinfo); 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
429withRetry-Afterfor 15 minutes. The limiter is in-memory per instance behind aLoginRateLimiterinterface (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 FORBIDDENregardless of whether the target resource exists (no enumeration). - Address queries are scoped by
user_idin SQL, so a valid foreign address id is unreachable. GET /userslists 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
Authenticatefunction 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:boundariesenforces these rules.