- 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
142 lines
6.8 KiB
Markdown
142 lines
6.8 KiB
Markdown
# MercadoDeVida backend — modular monolith skeleton
|
|
|
|
TypeScript + Fastify modular monolith. Simple code, clear modules, small changes, no magic.
|
|
|
|
## Requirements
|
|
|
|
- Node.js >= 22
|
|
- npm
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
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)
|
|
|
|
```bash
|
|
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
|
|
|
|
```text
|
|
<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
|
|
|
|
```text
|
|
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.
|