feat(ADM-018): completed feature
This commit is contained in:
78
legacy/specs-old/F-001-scaffold/DESIGN.md
Normal file
78
legacy/specs-old/F-001-scaffold/DESIGN.md
Normal 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.
|
||||
55
legacy/specs-old/F-001-scaffold/SPEC.md
Normal file
55
legacy/specs-old/F-001-scaffold/SPEC.md
Normal 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.
|
||||
13
legacy/specs-old/F-001-scaffold/TASKS.md
Normal file
13
legacy/specs-old/F-001-scaffold/TASKS.md
Normal 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.
|
||||
25
legacy/specs-old/F-001-scaffold/TESTS.md
Normal file
25
legacy/specs-old/F-001-scaffold/TESTS.md
Normal 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 |
|
||||
64
legacy/specs-old/F-002-database-foundation/DESIGN.md
Normal file
64
legacy/specs-old/F-002-database-foundation/DESIGN.md
Normal 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`.
|
||||
58
legacy/specs-old/F-002-database-foundation/SPEC.md
Normal file
58
legacy/specs-old/F-002-database-foundation/SPEC.md
Normal 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.
|
||||
10
legacy/specs-old/F-002-database-foundation/TASKS.md
Normal file
10
legacy/specs-old/F-002-database-foundation/TASKS.md
Normal 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
|
||||
25
legacy/specs-old/F-002-database-foundation/TESTS.md
Normal file
25
legacy/specs-old/F-002-database-foundation/TESTS.md
Normal 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 |
|
||||
54
legacy/specs-old/F-003-http-foundation/DESIGN.md
Normal file
54
legacy/specs-old/F-003-http-foundation/DESIGN.md
Normal 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.
|
||||
37
legacy/specs-old/F-003-http-foundation/SPEC.md
Normal file
37
legacy/specs-old/F-003-http-foundation/SPEC.md
Normal 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`.
|
||||
11
legacy/specs-old/F-003-http-foundation/TASKS.md
Normal file
11
legacy/specs-old/F-003-http-foundation/TASKS.md
Normal 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
|
||||
21
legacy/specs-old/F-003-http-foundation/TESTS.md
Normal file
21
legacy/specs-old/F-003-http-foundation/TESTS.md
Normal 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.
|
||||
78
legacy/specs-old/F-004-config-flags/DESIGN.md
Normal file
78
legacy/specs-old/F-004-config-flags/DESIGN.md
Normal 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.
|
||||
41
legacy/specs-old/F-004-config-flags/SPEC.md
Normal file
41
legacy/specs-old/F-004-config-flags/SPEC.md
Normal 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 1–65535) |
|
||||
| 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).
|
||||
10
legacy/specs-old/F-004-config-flags/TASKS.md
Normal file
10
legacy/specs-old/F-004-config-flags/TASKS.md
Normal 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
|
||||
22
legacy/specs-old/F-004-config-flags/TESTS.md
Normal file
22
legacy/specs-old/F-004-config-flags/TESTS.md
Normal 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.
|
||||
113
legacy/specs-old/F-005-identity/DESIGN.md
Normal file
113
legacy/specs-old/F-005-identity/DESIGN.md
Normal 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.
|
||||
37
legacy/specs-old/F-005-identity/SPEC.md
Normal file
37
legacy/specs-old/F-005-identity/SPEC.md
Normal 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`.
|
||||
13
legacy/specs-old/F-005-identity/TASKS.md
Normal file
13
legacy/specs-old/F-005-identity/TASKS.md
Normal 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
|
||||
25
legacy/specs-old/F-005-identity/TESTS.md
Normal file
25
legacy/specs-old/F-005-identity/TESTS.md
Normal 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)
|
||||
99
legacy/specs-old/F-006-users-rbac/DESIGN.md
Normal file
99
legacy/specs-old/F-006-users-rbac/DESIGN.md
Normal 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').
|
||||
31
legacy/specs-old/F-006-users-rbac/SPEC.md
Normal file
31
legacy/specs-old/F-006-users-rbac/SPEC.md
Normal 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.
|
||||
14
legacy/specs-old/F-006-users-rbac/TASKS.md
Normal file
14
legacy/specs-old/F-006-users-rbac/TASKS.md
Normal 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
|
||||
20
legacy/specs-old/F-006-users-rbac/TESTS.md
Normal file
20
legacy/specs-old/F-006-users-rbac/TESTS.md
Normal 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
|
||||
Reference in New Issue
Block a user