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,78 @@
# DESIGN — F-001 Scaffold modular monolith skeleton
## Affected modules
- New: `project/` application skeleton (no business modules yet, only exemplar `health` module).
## Modules touched
- `src/modules/health` (exemplar module proving the layout and boundary rule)
- `src/shared` (error envelope helper)
- `src/infrastructure/http` (Fastify server bootstrap)
- `src/app` (composition root)
## Modules NOT touched
- Everything else. No business modules exist yet. No harness files outside `work/artifacts/` and `specs/`.
## New interfaces
- `health` module public API: `registerHealthRoutes(app: FastifyInstance): Promise<void>` exported only from `src/modules/health/index.ts`.
- Boundary checker script: `node scripts/check-module-boundaries.mjs src` → exit 0 ok / exit 1 violation.
## API changes
- Adds `GET /health``200 {"status":"ok"}`.
## Database changes
- None.
## Events
- None.
## External integrations
- None.
## Cache changes
- None.
## Security considerations
- Fastify default JSON error handler replaced with an envelope that never leaks stack traces.
- No dependencies beyond Fastify + toolchain.
## Layout
```text
project/
├── package.json
├── tsconfig.json
├── eslint.config.mjs
├── vitest.config.ts
├── .gitignore
├── scripts/
│ └── check-module-boundaries.mjs
└── src/
├── app/
│ └── build-app.ts # composition root: wires modules
├── infrastructure/
│ └── http/
│ └── server.ts # listen entrypoint
├── modules/
│ └── health/
│ ├── index.ts # public API
│ ├── api/
│ │ └── health.routes.ts
│ └── tests/
│ └── health.test.ts
└── shared/
└── errors.ts
```
## Boundary rules enforced by the checker
1. Files inside `src/modules/<mod>/` may only import: own subtree (relative), `src/shared/...` (relative or alias-free path), Node builtins, and npm packages.
2. Any relative import escaping `src/modules/<mod>/` toward another module or toward `src/app`/`src/infrastructure` is a violation.
3. Files outside modules (`src/app`, `src/infrastructure`) may import a module only via its `index.ts` (direct deep import = violation).
## Toolchain
- TypeScript strict, Fastify 5, Vitest, ESLint (flat config) + Prettier, tsc build to `dist/`.
## Migration strategy
- None (greenfield).
## Rollback strategy
- Delete `project/` content added by this ticket; no other system depends on it yet.

View File

@@ -0,0 +1,55 @@
# SPEC — F-001 Scaffold modular monolith skeleton
## Problem
No codebase exists. MercadoDeVida vNext needs a boring, typed, modular home before any business feature.
## Goal
TypeScript modular monolith skeleton with strict module boundaries and a green toolchain.
## Non-goals
- No business logic
- No database access
- No frontend
## User story
As a developer (human or AI), I can run install/build/lint/typecheck/test from `project/` and get a running HTTP app with a health endpoint, so every later feature starts from a known green base.
## Functional requirements
1. `project/` contains a Node + TypeScript app with Fastify.
2. Layout: `src/modules/`, `src/shared/`, `src/infrastructure/`, `src/app/`.
3. Every module folder exposes its public API through `index.ts`.
4. A boundary checker fails when a module imports another module's internal files.
5. `GET /health` returns 200 `{"status":"ok"}`.
## Business rules
- Modules communicate only through public interfaces (`index.ts`).
- Composition happens only in `src/app` (composition root).
## Inputs
- `GET /health`: none.
## Outputs
- `GET /health`: `200 application/json {"status":"ok"}`.
## Edge cases
- Invalid route → 404 JSON envelope.
- Server start failure (port busy) → non-zero exit with clear error.
## Acceptance criteria
1. `npm install`, `npm run build`, `npm run lint`, `npm run typecheck`, `npm test` all green in `project/`.
2. `GET /health` returns HTTP 200 with `{"status":"ok"}`.
3. `src/modules`, `src/shared`, `src/infrastructure`, `src/app` exist.
4. A module importing another module's internal file fails the boundary check (demonstrated by test).
5. `./scripts/verify.sh` green at repo root.
## Dependencies
- None (first ticket).
## Security implications
- No secrets, no auth surface. Error responses must not leak stack traces.
## SEO implications
- None.
## Performance implications
- None measurable yet; baseline latency observable via test only.

View File

@@ -0,0 +1,13 @@
# TASKS — F-001 Scaffold modular monolith skeleton
- TASK-001 Init `project/` package: package.json (type module), tsconfig strict, .gitignore
- TASK-002 Add Fastify + shared error envelope + `build-app.ts` composition root
- TASK-003 Add `health` exemplar module (index.ts public API, routes, tests)
- TASK-004 Add HTTP server entrypoint (`src/infrastructure/http/server.ts`)
- TASK-005 Add boundary checker script + fixtures proving it fails on violations
- TASK-006 Add toolchain: ESLint flat config (incl. no deep cross-module imports), Prettier, Vitest
- TASK-007 Wire npm scripts: build, lint, lint:boundaries, typecheck, test, start
- TASK-008 Write project README with run commands
- TASK-009 Run full verification and write implementer evidence
Each task is independently understandable and leaves the tree consistent.

View File

@@ -0,0 +1,25 @@
# TESTS — F-001 Scaffold modular monolith skeleton
## Unit / integration (Vitest)
- `src/modules/health/tests/health.test.ts`: module-scoped; registers the module public API on a bare Fastify instance; `GET /health` → 200 and `{"status":"ok"}`.
- `src/app/tests/build-app.test.ts`: composition root; health wired through `buildApp()`; unknown route → 404 JSON envelope without stack leak.
- `scripts/tests/boundary-checker.test.ts`: run `check-module-boundaries.mjs` against fixture trees:
- fixture `ok` (imports inside own module + shared) → exit 0
- fixture `cross-module-internal` (imports another module's `domain/`) → exit 1
- fixture `deep-from-app` (app imports `modules/x/api/...` directly) → exit 1
## Toolchain checks (manual/CI commands)
- `npm run build` exits 0
- `npm run typecheck` exits 0
- `npm run lint` exits 0
- `npm run lint:boundaries` exits 0 on clean tree
- `npm test` exits 0
## Acceptance traceability
| Criterion | Evidence |
|---|---|
| install/build/lint/typecheck/test green | command outputs in implementer.md |
| GET /health 200 ok | health.test.ts + build-app.test.ts |
| src/{modules,shared,infrastructure,app} exist | tree listing in implementer.md |
| boundary violation fails lint | boundary-checker.test.ts fixtures |
| verify.sh green | qa.json evidence |

View File

@@ -0,0 +1,64 @@
# DESIGN — F-002 Database foundation with module-owned schemas
## Affected modules
- `src/infrastructure/db` (new): pool factory + migration runner entry.
## Modules touched
- `src/infrastructure/db`
- `project/migrations/` (new folder, SQL files)
- `project/docker-compose.yml` (new)
## Modules NOT touched
- All business modules. No HTTP API change. No shared/ change.
## New interfaces
- `src/infrastructure/db/pool.ts`: `createPoolFromEnv(): Pool` (fail fast if `DATABASE_URL` missing), `query(text, params)` helper typed over pg.
- npm scripts: `db:up`, `db:down`, `db:status`, `docker:up`, `docker:down`, `test:integration`.
## API changes
- None.
## Database changes
- Baseline migration `001_baseline.js` (node-pg-migrate, pgm.sql):
- `CREATE EXTENSION IF NOT EXISTS citext;`
- `CREATE EXTENSION IF NOT EXISTS pgcrypto;`
- `CREATE TABLE app_meta (key text PRIMARY KEY, value text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now());`
- Down: drop table, drop extensions.
- Migration version tracking table owned by the migration tool.
## Naming convention (documented rule)
```text
<module>_<table> e.g. catalog_products, inventory_stock, orders_orders
<module>_<table>_id_seq sequences owned by their table
```
- A module never queries tables without its own prefix.
- Migrations are immutable once merged; fixes ship as new migrations.
## Events
- None.
## External integrations
- Docker Compose: postgres:16-alpine (port 5432), redis:7-alpine (port 6379), named volumes.
## Cache changes
- None (Redis present but unused until cache ticket).
## Security considerations
- Compose credentials are dev-only and public by design; documented as such.
- `.env.example` committed, real `.env` gitignored.
- No secrets in migration files.
## Toolchain decisions
- Migration tool: **node-pg-migrate** (pure npm dependency, battle-tested, SQL-first via `pgm.sql`, supports up/down and dry-run). Rejected: hand-rolled runner (reinvention), Flyway/golang-migrate (foreign toolchains for a Node monolith).
- Driver: **pg** (standard).
- Env loading: Node 22 `--env-file` flag; no dotenv dependency.
## Test strategy
- Integration tests (`*.itest.ts`) run only when `TEST_DATABASE_URL` is set: `describe.skipIf` — explicit, no magic.
- They verify: fresh up creates schema; second up is no-op; down reverts cleanly; `app_meta` usable via pool helper.
## Migration strategy
- Greenfield; compose starts empty volume, migrations run from zero.
## Rollback strategy
- `db:down` reverts last migration; compose volumes can be removed with `docker compose down -v`.

View File

@@ -0,0 +1,58 @@
# SPEC — F-002 Database foundation with module-owned schemas
## Problem
Modules need PostgreSQL with clear ownership and safe migrations. No schema tooling exists yet.
## Goal
Migration tooling, table naming convention per module, and a local dev database (PostgreSQL + Redis) that starts with one command.
## Non-goals
- No business tables yet (they arrive with their modules).
- No Redis usage beyond making the service available for future tickets.
- No production deployment concerns.
## User story
As a developer, I can run one command to get PostgreSQL + Redis locally, apply migrations forward and backward deterministically, and every future module knows exactly how to name and own its tables.
## Functional requirements
1. SQL migrations run through a deterministic tool, tracked in the database, ordered, idempotent per version.
2. Migrations support up and down.
3. Naming convention `<module>_<table>` is documented and visible in the baseline migration.
4. `docker-compose.yml` provides PostgreSQL 16 and Redis 7 with one command.
5. A typed DB access point lives in `src/infrastructure/db/` (pool creation from env, fail fast on missing config).
6. Baseline migration: enable extensions + `app_meta` key/value table (foundation-only, not business).
## Business rules
- No schema change without migration.
- Modules own tables by prefix; cross-module table access is forbidden (enforced later at module API level, documented now).
## Inputs
- `DATABASE_URL` (runtime), `TEST_DATABASE_URL` (integration tests).
## Outputs
- Migration CLI exit codes 0/1, log lines per applied/reverted migration.
## Edge cases
- Re-running `migrate up` on an up-to-date DB is a no-op.
- `migrate down` reverts exactly the last applied migration.
- Missing `DATABASE_URL` → clear error, non-zero exit, no partial state.
## Acceptance criteria
1. Given a fresh database, When migrations run, Then schema is created and repeating the run is a no-op.
2. Given applied migrations, When down runs, Then schema rolls back cleanly.
3. Table naming convention documented and enforced (documented rule + exemplar).
4. Dev PostgreSQL and Redis start with one command.
5. `verify.sh` green.
## Dependencies
- F-001 (project skeleton).
## Security implications
- Dev credentials live only in docker-compose dev file and `.env.example`; never real secrets in repo.
- DB user for tests should be dedicated (documented).
## SEO implications
- None.
## Performance implications
- Pool defaults conservative (max 10); no caching layer yet.

View File

@@ -0,0 +1,10 @@
# TASKS — F-002 Database foundation with module-owned schemas
- TASK-001 Add deps: pg, node-pg-migrate (+ @types/pg dev); justify in spec/tech.md
- TASK-002 Add `project/docker-compose.yml` (postgres:16-alpine + redis:7-alpine, volumes, dev creds) and `.env.example`
- TASK-003 Add baseline migration `migrations/001_baseline.js` (node-pg-migrate; extensions + app_meta via pgm.sql, with down)
- TASK-004 Add `src/infrastructure/db/pool.ts` (createPoolFromEnv fail-fast + query helper)
- TASK-005 Wire npm scripts: db:up, db:down, db:status, docker:up, docker:down, test:integration
- TASK-006 Integration tests: fresh up / no-op rerun / down rollback / pool helper roundtrip (skipIf no TEST_DATABASE_URL)
- TASK-007 Document naming convention in project/README.md
- TASK-008 Run full verification and write implementer evidence

View File

@@ -0,0 +1,25 @@
# TESTS — F-002 Database foundation with module-owned schemas
## Integration (Vitest, `*.itest.ts`, run with TEST_DATABASE_URL set)
- `migrations.itest.ts`:
- fresh database + `migrate up``app_meta` exists
- second `migrate up` → no-op (no errors, same state)
- `migrate down``app_meta` gone
- `pool.itest.ts`:
- `createPoolFromEnv` connects and `SELECT 1` roundtrip
- insert/read/delete row in `app_meta` via query helper
## Manual/CI commands
- `npm run docker:up` starts PostgreSQL + Redis with one command
- `npm run db:up` on fresh DB exits 0; second run exits 0 with nothing applied
- `npm run db:down` exits 0 and drops baseline objects
- `npm test` stays green even without TEST_DATABASE_URL (integration tests skip explicitly)
## Acceptance traceability
| Criterion | Evidence |
|---|---|
| fresh up creates schema, rerun is no-op | migrations.itest.ts + db:up twice in implementer.md |
| down rolls back cleanly | migrations.itest.ts + db:down output |
| naming convention documented/enforced | README section + baseline migration exemplar |
| dev DBs start with one command | docker compose up output |
| verify.sh green | qa.json evidence |

View File

@@ -0,0 +1,54 @@
# DESIGN — F-003 HTTP foundation and request context
## Architecture
All cross-cutting HTTP behavior lives in explicit, readable code:
- `src/infrastructure/logging/logger.ts` — pino factory. JSON only, level from
`LOG_LEVEL` (default `info`), base field `service`. Accepts an optional writable
destination so tests can capture output without transports (no magic).
- `src/shared/errors.ts``AppError` class (statusCode, code, message) and the
single `errorEnvelope(statusCode, code, message, requestId)` builder. Envelope v2:
`{ error: { statusCode, code, message }, requestId }`.
- `src/shared/http-input.ts``parseJson(schema, input)` using zod. On failure
throws `AppError(400, VALIDATION_ERROR)` carrying field-level issues (field paths
only, never internal state).
- `src/app/build-app.ts` — composition root wires everything explicitly:
- Fastify `genReqId`: trust incoming `x-request-id` only if it matches
`^[A-Za-z0-9._-]{1,128}$`; otherwise generate `crypto.randomUUID()`.
- `onRequest` hook: set `x-request-id` response header, start timer.
- `onResponse` hook: one JSON log line per request
`{ requestId, method, url, statusCode, durationMs }`.
- `setErrorHandler`: map `AppError` and Fastify errors to the envelope. 4xx expose
their message; >=500 always returns generic `Internal Server Error`. Errors are
logged with stack server-side, tagged with requestId.
- `setNotFoundHandler`: envelope with code `NOT_FOUND` + requestId.
- `src/infrastructure/http/server.ts` — startup log lines through the logger.
## Request id policy
- Propagation is opt-in and sanitized; anything suspicious is replaced by a fresh UUID.
- Response header name: `x-request-id`.
## Validation policy
- Validation is explicit per route: call `parseJson(schema, body)` inside the handler.
- No schema decorators, no magic body binding. The F-003 deliverable is the hook
(helper + envelope integration), exercised by composition tests with a test-only
route registered on the built app.
## Backward compatibility
- `GET /health` response body unchanged; envelope gains fields (additive).
## Files
| File | Role |
|---|---|
| src/infrastructure/logging/logger.ts | pino factory |
| src/shared/errors.ts | AppError + envelope v2 |
| src/shared/http-input.ts | zod parse helper |
| src/app/build-app.ts | hooks, genReqId, handlers |
| src/infrastructure/http/server.ts | startup logging |
| src/app/tests/http-foundation.test.ts | composition tests (request id, logs, envelope, no leak) |
| src/shared/tests/http-input.test.ts | helper unit tests |
## Risks / mitigations
- Double logging (fastify internal + hooks): keep `logger: false` on Fastify, log only via explicit hooks.
- Header injection through propagated id: strict regex + length cap.
- 5xx leaking internals: handler returns fixed generic message for anything >=500; tests assert absence of thrown message/stack.

View File

@@ -0,0 +1,37 @@
# SPEC — F-003 HTTP foundation and request context
## Problem
Every request needs identity, structured logs and predictable errors. Today the app
answers with ad-hoc console logging potential, no request correlation id, and an
error envelope without request context.
## Goal
request_id everywhere, JSON logs, one error envelope, no magic.
## Scope IN
- request_id generated or propagated on every request
- Structured JSON logging with request_id
- Single error response envelope (v2: adds stable `code` + `requestId`)
- Input validation hook on API layer (foundation helper, explicit usage)
## Scope OUT
- No tracing backend
- No metrics backend
- No production endpoints that consume validation yet (first consumer: F-005 identity)
## Acceptance criteria
1. Every response carries `request_id` (via `x-request-id` response header; error bodies also embed it).
2. Every log line for a request carries the same request_id.
3. Given an invalid request When handled Then the error envelope shape is stable.
4. Internal stack traces never leak to API responses (5xx message is generic; stack stays server-side in logs).
5. `./scripts/verify.sh` green.
## Non-functional
- JSON-only logs (no pretty printers in runtime).
- Incoming `x-request-id` must be sanitized before reuse (length/charset limits).
- No new framework magic: logging hooks and error handler are explicit code in the composition root.
## Dependencies added
- pino (JSON structured logging; Fastify ecosystem standard)
- zod (explicit input validation; no decorators/annotations magic)
Justification goes to `spec/tech.md`.

View File

@@ -0,0 +1,11 @@
# TASKS — F-003 HTTP foundation and request context
- [ ] TASK-001 deps: add pino + zod; justify both in spec/tech.md
- [ ] TASK-002 src/infrastructure/logging/logger.ts: createLogger({ level?, destination? })
- [ ] TASK-003 src/shared/errors.ts: AppError + errorEnvelope v2 (code + requestId)
- [ ] TASK-004 src/shared/http-input.ts: parseJson(schema, input) -> AppError(400, VALIDATION_ERROR)
- [ ] TASK-005 src/app/build-app.ts: genReqId sanitization, onRequest/onResponse hooks, error + notFound handlers with requestId; buildApp(deps) injectable logger
- [ ] TASK-006 src/infrastructure/http/server.ts: logger-based startup lines
- [ ] TASK-007 tests: src/shared/tests/http-input.test.ts
- [ ] TASK-008 tests: src/app/tests/http-foundation.test.ts (id propagation/sanitization, log correlation, envelope stability incl. malformed JSON, no stack leak)
- [ ] TASK-009 run lint + boundaries + typecheck + build + test + verify.sh

View File

@@ -0,0 +1,21 @@
# TESTS — F-003 HTTP foundation and request context
## Unit — src/shared/tests/http-input.test.ts
1. parseJson returns parsed value for valid input
2. parseJson throws AppError 400 VALIDATION_ERROR with field issues for invalid input
3. parseJson rejects non-object input when schema expects object
## Composition — src/app/tests/http-foundation.test.ts
(app built with logger writing to an in-memory stream)
1. GET /health responds 200 with x-request-id header (UUID shape)
2. Valid incoming x-request-id is propagated: same value in response header and in the request log line
3. Malicious x-request-id (bad charset / >128 chars) is replaced by generated UUID; log line carries the generated id
4. Unknown route -> 404 envelope { error: { statusCode: 404, code: NOT_FOUND, message }, requestId } + x-request-id header
5. Invalid JSON body on test route -> 400, envelope shape stable, requestId present
6. Schema-invalid body on test route -> 400 code VALIDATION_ERROR, issues mention field path only
7. Handler throwing Error('secret internal detail') -> 500 generic message, response body contains neither the message nor a stack; error log line contains requestId and stack
8. Every request produces exactly one 'request completed' log line carrying the requestId
## Manual / QA
- verify.sh green; live curl shows x-request-id header and JSON envelope on unknown route.

View File

@@ -0,0 +1,78 @@
# DESIGN — F-004 Typed config and feature flags
## Layering
- `src/infrastructure/config/config.ts` — pure config loader. Owns env parsing and
validation. No side effects on `process.env`; takes an env object so tests are
deterministic. Throws a single `ConfigError` listing every problem found.
- `src/modules/flags/` — the feature-flag module. Business-facing, boundary-checked.
Exposes only `index.ts`. Contains the `FeatureFlagProvider` interface and a simple
in-memory store. Does NOT depend on infrastructure/config (kept independent so it
can later be backed by a DB/external service without touching config).
## Config loader (infrastructure)
```ts
export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
host: string;
logLevel: string;
databaseUrl: string; // required
redisUrl?: string; // optional
flags: Record<string, boolean>; // parsed FLAG_* vars (initial seed)
}
export class ConfigError extends Error { problems: string[] }
export function loadConfig(env: StringRecord): AppConfig
```
- `DATABASE_URL` required: missing -> ConfigError naming the var.
- `PORT` parsed to int, validated 1..65535.
- `NODE_ENV` whitelisted, default `development`.
- `FLAG_<NAME>` vars: value `true`/`false` (case-insensitive) -> boolean; other values
-> ConfigError naming the var. Names lowercased after stripping `FLAG_` prefix.
- Accumulate all problems, then throw once (fail fast AND clear).
## Feature flag module
```ts
export interface FeatureFlagProvider {
isEnabled(name: string): boolean;
}
export class InMemoryFeatureFlagStore implements FeatureFlagProvider {
constructor(initial?: Record<string, boolean>)
isEnabled(name): boolean // unknown flag -> false (fail-safe default)
setEnabled(name, enabled): void // runtime mutation, no redeploy needed
}
export function createFlagStore(initial?): InMemoryFeatureFlagStore
```
- Unknown flags are OFF by default (fail-safe: risky paths stay skipped).
- `setEnabled` mutates live state: deployment ≠ activation.
- Guarded-path helper kept trivial; consumers do `if (flags.isEnabled('x')) { ... }`.
## Wiring (composition root)
- `src/app/build-app.ts` stays DB-free for now; but `buildApp` accepts an optional
`flags` dep so routes can guard paths. Default: empty store (all flags off).
- `src/infrastructure/http/server.ts` calls `loadConfig(process.env)`, uses typed
`port`/`host`, and passes `config.flags` into the app. On ConfigError it logs the
problems and exits non-zero.
- `pool.ts` keeps `createPoolFromEnv` but gains an optional typed overload note; no
breaking change needed this ticket (config owns DATABASE_URL validation at startup).
## Boundary notes
- `flags` module: only imports from `shared` (currently nothing needed). It must not
import `infrastructure/config`. Config parses FLAG_* into a plain record and hands it
to the module at composition time — inversion keeps the module clean.
- Boundary checker will enforce this automatically.
## Files
| File | Role |
|---|---|
| src/infrastructure/config/config.ts | typed loader + ConfigError |
| src/infrastructure/config/tests/config.test.ts | loader unit tests |
| src/modules/flags/index.ts | module public API |
| src/modules/flags/domain/feature-flag-store.ts | interface + store |
| src/modules/flags/tests/feature-flag-store.test.ts | store unit tests |
| src/app/build-app.ts | optional flags dep injection |
| src/infrastructure/http/server.ts | loadConfig + typed listen + ConfigError exit |
## Risks / mitigations
- Silent flag default: unknown -> false, documented; tested.
- ConfigError leaking secrets: messages name var NAMES only, never values.
- Boundary violation (flags importing config): prevented by composition-time injection + checker.

View File

@@ -0,0 +1,41 @@
# SPEC — F-004 Typed config and feature flags
## Problem
Risky features need activation separate from deployment; env access must be typed.
Today env access is ad-hoc (`process.env.X ?? fallback` scattered in entrypoints)
and there is no activation mechanism distinct from deploy.
## Goal
Fail-fast typed config plus a simple feature flag module behind an interface.
## Scope IN
- Typed env config loader in `src/infrastructure/config`, fail fast on missing/invalid required vars (all problems reported in one clear message)
- `FeatureFlagProvider` interface with simple in-memory store implementation (`src/modules/flags`)
- Deployment ≠ activation: flags seed from `FLAG_*` env vars at boot and can be mutated at runtime without redeploy
- DB pool refactor to consume config instead of sniffing env itself
## Scope OUT
- No external flag service
- No per-user segmentation
- No admin HTTP endpoint for flags yet (first consumer decides shape; store API is runtime-mutable already)
## Required vs optional vars
| Var | Required | Default |
|---|---|---|
| DATABASE_URL | yes | — |
| PORT | no | 3000 (valid: integer 165535) |
| HOST | no | 0.0.0.0 |
| LOG_LEVEL | no | info |
| NODE_ENV | no | development (valid: development/test/production) |
| REDIS_URL | no | undefined |
| FLAG_<NAME> | no | parsed as true/false |
## Acceptance criteria
1. Given a missing required env var When the app starts Then startup fails with a clear message naming the var.
2. Given flag off When a code path guarded by the flag runs Then the path is skipped.
3. Flag state change does not require redeploy (runtime `setEnabled` on the store).
4. `./scripts/verify.sh` green.
## Non-functional
- No new dependencies: hand-rolled validation is small, explicit and boring.
- Config loader is a pure function of an env object (testable without touching process.env).

View File

@@ -0,0 +1,10 @@
# TASKS — F-004 Typed config and feature flags
- [ ] TASK-001 src/infrastructure/config/config.ts: AppConfig, ConfigError, loadConfig (pure over env object)
- [ ] TASK-002 src/infrastructure/config/tests/config.test.ts: required/optional/invalid cases
- [ ] TASK-003 src/modules/flags/domain/feature-flag-store.ts: FeatureFlagProvider + InMemoryFeatureFlagStore + createFlagStore
- [ ] TASK-004 src/modules/flags/index.ts: public API
- [ ] TASK-005 src/modules/flags/tests/feature-flag-store.test.ts: guard + runtime mutation
- [ ] TASK-006 src/app/build-app.ts: optional flags dep
- [ ] TASK-007 src/infrastructure/http/server.ts: loadConfig, typed listen, ConfigError exit
- [ ] TASK-008 run lint + boundaries + typecheck + build + test + verify.sh

View File

@@ -0,0 +1,22 @@
# TESTS — F-004 Typed config and feature flags
## Unit — src/infrastructure/config/tests/config.test.ts
1. Valid full env -> typed AppConfig (port number, nodeEnv enum, flags parsed)
2. Missing DATABASE_URL -> ConfigError naming DATABASE_URL
3. Invalid PORT (non-numeric / out of range) -> ConfigError naming PORT
4. Invalid NODE_ENV -> ConfigError
5. FLAG_* true/false parsed; unknown flag value -> ConfigError naming the var
6. Multiple problems -> single ConfigError listing all of them
## Unit — src/modules/flags/tests/feature-flag-store.test.ts
1. Known flag on -> isEnabled true; known flag off -> false
2. Unknown flag -> false (fail-safe)
3. setEnabled flips state at runtime (no new store / no redeploy)
4. createFlagStore seeds initial state
## Composition
5. buildApp with flags dep: guarded path skipped when flag off, runs when on
## Manual / QA
- Start server without DATABASE_URL -> clear error naming the var, non-zero exit.
- verify.sh green.

View File

@@ -0,0 +1,113 @@
# DESIGN — F-005 Identity: register, login, sessions
## Module layout (hexagonal, screaming)
```
src/modules/identity/
index.ts # public API only: registerIdentityRoutes + types
domain/
user.ts # User, NewUser types; email normalization
session.ts # Session type, token TTL constants
errors.ts # InvalidCredentials, EmailAlreadyRegistered, RateLimited
ports.ts # PasswordHasher, UserRepository, SessionRepository
application/
register-user.ts # RegisterUser use case
login.ts # Login use case (rate limit + timing equalization)
logout.ts # Logout use case (revoke by token hash)
login-rate-limiter.ts # LoginRateLimiter interface + InMemoryLoginRateLimiter
infrastructure/
argon2-password-hasher.ts # argon2id, OWASP params
pg-user-repository.ts # identity_users CRUD (pg parameterized)
pg-session-repository.ts # identity_sessions create/revoke-by-token-hash
session-token.ts # random token + sha256 hash helpers
api/
identity.routes.ts # POST /auth/register | /auth/login | /auth/logout
tests/
login-rate-limiter.test.ts # unit
session-token.test.ts # unit
```
Rules respected: domain imports nothing from infra/frameworks; application depends on
domain ports only; infrastructure implements ports; api is a thin adapter using
`parseJson` (F-003) and maps domain errors to AppError.
## Data model — migration 002_identity
```sql
identity_users(
id uuid PK DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
identity_sessions(
id uuid PK DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE, -- sha256 of the opaque cookie token
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
) + index on user_id
```
- citext for case-insensitive unique email (extension from baseline).
- Only the token HASH is stored: a DB leak does not yield usable sessions.
- Down drops both tables (reverse FK order).
## Password hashing
- argon2id, memoryCost 19456 KiB, timeCost 2, parallelism 1 (OWASP 2024 baseline).
- Output PHC string (`$argon2id$...`) stored as-is; verify via argon2.verify.
- Timing equalization: unknown email still runs one verify against a module-level
dummy hash so 401 timing does not enumerate users.
## Sessions and cookie
- Token: `crypto.randomBytes(64).toString('base64url')` (opaque, 512 bits).
- TTL: 7 days, stamped in `expires_at`; expired/revoked sessions are invalid.
- Cookie `mdv_session`: HttpOnly, SameSite=Lax, Path=/, Secure per config
(`COOKIE_SECURE`, default true; `.env.example` sets false for local http dev),
Max-Age = TTL.
- Logout revokes by token hash (`revoked_at`), always answers 204 and clears the
cookie (idempotent).
## Rate limiting
```ts
interface LoginRateLimiter {
consume(key: string): { allowed: true } | { allowed: false; retryAfterMs: number };
recordFailure(key: string): void;
reset(key: string): void;
}
```
- InMemoryLoginRateLimiter: per normalized email, threshold 10 consecutive failures,
15-minute cooldown once reached, reset on success. Lazy sweep of expired entries.
- In-memory per instance (documented limitation; interface allows Redis-backed swap).
- 429 response includes `Retry-After` seconds.
## Endpoints
| Route | Success | Errors |
|---|---|---|
| POST /auth/register | 201 `{ id, email, createdAt }` | 400 VALIDATION_ERROR, 409 EMAIL_ALREADY_REGISTERED |
| POST /auth/login | 200 `{ id, email }` + Set-Cookie | 401 INVALID_CREDENTIALS (generic), 429 TOO_MANY_ATTEMPTS |
| POST /auth/logout | 204 + clear cookie | — (idempotent) |
Request bodies validated with zod via `parseJson` (F-003 hook): email + password
(min 8, max 128 chars).
## Wiring
- `pool.ts`: `createPool(connectionString)` replaces `createPoolFromEnv` (config owns env).
- `config.ts`: adds optional `COOKIE_SECURE` (default true).
- `buildApp(deps)`: new optional `pool` + `cookieSecure`; when pool present it registers
identity routes. No pool = foundation-only app (keeps F-001..F-004 tests DB-free).
- `server.ts`: creates pool from config, passes into buildApp.
## Test strategy
- Unit (module-scoped): rate limiter thresholds/reset/cooldown; session token
randomness/hashing; domain error mapping.
- Composition (app-scoped itest, real PostgreSQL): register/login/logout happy paths;
argon2 PHC hash in DB + no plaintext; duplicate email 409; identical 401 bodies for
wrong password vs unknown email; cookie attributes; 10 failures -> 429 + Retry-After;
logout revokes (second logout idempotent, session unusable).
## Risks / mitigations
- Native argon2 build: toolchain present; if install fails, switch impl behind the
PasswordHasher port to @node-rs/argon2 (no design change).
- Enumeration: generic 401 + dummy verify; register 409 accepted as standard contract
(login is the enumeration-sensitive path per acceptance).
- In-memory rate limiter bypass via multi-instance: documented; interface ready for Redis.

View File

@@ -0,0 +1,37 @@
# SPEC — F-005 Identity: register, login, sessions
## Problem
Customers need accounts; nothing trusts who calls the API.
## Goal
Registration, login, logout with hashed passwords and secure sessions — server is the
only authority for identity (never trust the frontend).
## Scope IN
- `src/modules/identity` with hexagonal layers: domain / application / infrastructure / api
- Register, login, logout use cases over PostgreSQL (source of truth)
- Argon2id password hashing (OWASP parameters)
- Server-side opaque sessions stored in DB; cookie carries a random token, DB stores only its SHA-256 hash
- Secure session cookie: HttpOnly, Secure, SameSite
- Login rate limiting (10 consecutive failures -> 429 with cooldown) behind an interface
- Migration 002_identity (identity_users, identity_sessions) with down
- Config: COOKIE_SECURE flag (default true)
## Scope OUT
- No MFA, no OAuth providers, no profile editing
- No email verification flow, no password reset
- No distributed rate limiting (in-memory per instance; documented)
- No expired-session sweeper (follow-up)
## Acceptance criteria
1. Given valid credentials When login Then HTTP 200 and secure session cookie set.
2. Given wrong password When login Then HTTP 401 and no user enumeration hint (unknown email returns the identical 401).
3. Passwords stored with argon2, never plaintext or reversible.
4. Given 10 failed logins in a row When next login attempted Then HTTP 429.
5. Session cookie is HttpOnly, Secure and SameSite.
6. `./scripts/verify.sh` green.
## Dependencies added
- argon2 (canonical Argon2id implementation; PHC string output)
- @fastify/cookie (explicit setCookie/clearCookie; official Fastify plugin)
Justification goes to `spec/tech.md`.

View File

@@ -0,0 +1,13 @@
# TASKS — F-005 Identity: register, login, sessions
- [ ] TASK-001 deps: argon2 + @fastify/cookie; justify in spec/tech.md
- [ ] TASK-002 migrations/002_identity.js (identity_users + identity_sessions, reversible)
- [ ] TASK-003 domain: user.ts, session.ts, errors.ts, ports.ts
- [ ] TASK-004 application: register-user.ts, login.ts, logout.ts, login-rate-limiter.ts
- [ ] TASK-005 infrastructure: argon2 hasher, pg repos, session-token
- [ ] TASK-006 api: identity.routes.ts (zod via parseJson, error mapping, cookie)
- [ ] TASK-007 index.ts public API
- [ ] TASK-008 foundation wiring: pool.createPool(connectionString), config COOKIE_SECURE, buildApp(pool, cookieSecure), server.ts pool creation
- [ ] TASK-009 unit tests: rate limiter + session token (module-scoped)
- [ ] TASK-010 integration tests: full flows against real PostgreSQL (app-scoped itest)
- [ ] TASK-011 run lint + boundaries + typecheck + build + test + test:integration + verify.sh

View File

@@ -0,0 +1,25 @@
# TESTS — F-005 Identity: register, login, sessions
## Unit — src/modules/identity/tests/login-rate-limiter.test.ts
1. Allows attempts below threshold; records failures
2. Blocks with retryAfterMs after 10 consecutive failures (AC4 logic)
3. Success resets the counter
4. Cooldown expiry re-allows attempts
## Unit — src/modules/identity/tests/session-token.test.ts
1. Tokens are unique and URL-safe
2. hashSessionToken is deterministic sha256 hex and never equals the raw token
## Integration — src/app/tests/identity.itest.ts (real PostgreSQL, fresh DB per run)
1. register: 201 + body {id, email, createdAt}; DB row stores $argon2id$ hash; plaintext password appears nowhere in the row (AC3)
2. register duplicate email: 409 EMAIL_ALREADY_REGISTERED
3. register invalid payload: 400 VALIDATION_ERROR
4. login valid: 200 + mdv_session cookie with HttpOnly; SameSite=Lax; Secure; Max-Age (AC1, AC5)
5. login wrong password: 401 INVALID_CREDENTIALS; unknown email returns byte-identical error body (AC2)
6. rate limit: 10 failed logins then next attempt -> 429 TOO_MANY_ATTEMPTS + Retry-After header (AC4)
7. logout: 204 + cookie cleared; session row revoked; second logout still 204 (idempotent)
8. buildApp without pool: /auth/login is 404 (foundation-only app preserved)
## QA live
- register/login/logout over HTTP with curl; cookie flags visible in Set-Cookie
- verify.sh green (AC6)

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