feat(F-003): HTTP foundation with request context and error envelope

- request_id generated or sanitized-propagated on every request (x-request-id)
- structured JSON logging (pino), one correlated line per request, injectable logger
- error envelope v2 { error: { statusCode, code, message, details? }, requestId }
- 5xx messages always generic; stack traces stay in server logs only
- explicit parseJson (zod) input validation hook at the API layer
- README HTTP contract section; deps justified in spec/tech.md
- all gates approved; verify.sh green
This commit is contained in:
rikrdo
2026-08-14 22:13:28 +02:00
parent 425fedd13e
commit 41f144d7bd
26 changed files with 779 additions and 29 deletions

View File

@@ -0,0 +1,22 @@
# Architect — F-003 HTTP foundation and request context
done -> work/artifacts/F-003/architect.md
## Deliverables
- specs/F-003-http-foundation/SPEC.md
- specs/F-003-http-foundation/DESIGN.md
- specs/F-003-http-foundation/TASKS.md
- specs/F-003-http-foundation/TESTS.md
## Key decisions
1. **pino** for JSON structured logging: Fastify ecosystem standard, fast, boring. Fastify's own logger stays OFF (`logger: false`); logging happens through explicit onRequest/onResponse hooks so there is exactly one log line per request and zero hidden log paths.
2. **zod** for the input validation hook: explicit `parseJson(schema, body)` calls inside handlers. No decorators, no magic binding. F-003 ships the hook; first real consumer is F-005 identity.
3. **request_id policy**: propagate incoming `x-request-id` only when it matches `^[A-Za-z0-9._-]{1,128}$` (header-injection defense); otherwise `crypto.randomUUID()`. Response header `x-request-id` on every response; error envelopes also embed requestId.
4. **Error envelope v2** (additive): `{ error: { statusCode, code, message }, requestId }`. Codes: NOT_FOUND, VALIDATION_ERROR, INTERNAL_ERROR, or upstream Fastify error codes for known 4xx. >=500 always answers the generic `Internal Server Error`; stack goes to logs tagged with requestId, never to the client.
5. **Logger injection**: `buildApp({ logger? })` — tests capture logs through an in-memory destination. No transports, no env sniffing in tests.
## Boundaries
- Cross-cutting code lives in `shared` and `infrastructure/logging`; composition wiring only in `src/app`. Modules (health) untouched.
## Open risks
- Envelope change touches F-001 test expectations (additive; tests will be updated in build).

View File

@@ -0,0 +1,18 @@
# Documenter — F-003 HTTP foundation and request context
done -> work/artifacts/F-003/documenter.md
## Why documentation applies
F-003 defines the API-wide HTTP contract (request id, error envelope, validation hook) that every future module consumes.
## Documents reviewed/produced
- `project/README.md`: new "HTTP contract" section — x-request-id policy, envelope shape + codes, no-stack-leak guarantee, parseJson validation pattern, LOG_LEVEL. (Applied via a build-stage bounce because `project/` writes require build/implementer/running.)
- `spec/tech.md`: pino + zod justification rows added at build stage.
- `specs/F-003-http-foundation/` SPEC/DESIGN/TASKS/TESTS consistent with implementation.
## Verification
- README HTTP contract section matches tested behavior (cross-checked against qa.json live probes and test suite).
- Envelope documented as v2 (additive over F-001), codes enumerated.
## Pending docs (not this ticket)
- Per-module validation schemas documented as modules land (F-005+).

View File

@@ -0,0 +1,43 @@
# Implementer — F-003 HTTP foundation and request context
done -> work/artifacts/F-003/implementer.md
## Files created
- src/infrastructure/logging/logger.ts (pino factory; JSON only; injectable destination for tests)
- src/shared/http-input.ts (parseJson zod helper -> AppError 400 VALIDATION_ERROR with field paths)
- src/app/tests/http-foundation.test.ts (9 tests: id generation/propagation/sanitization, log correlation, envelope stability, no stack leak, one log line per request)
- src/shared/tests/http-input.test.ts (3 tests)
## Files modified
- src/shared/errors.ts: AppError class + error envelope v2 `{ error: { statusCode, code, message, details? }, requestId }` (additive over F-001 shape)
- src/app/build-app.ts: genReqId sanitization (^[A-Za-z0-9._-]{1,128}$ else randomUUID), onRequest/onResponse hooks (x-request-id header + one JSON log line per request), explicit setErrorHandler/setNotFoundHandler with requestId; buildApp({ logger? }) injectable
- src/infrastructure/http/server.ts: startup/shutdown logging via injected logger
- spec/tech.md: pino + zod justification
- package.json: deps pino, zod
## API changes
- Every response now carries `x-request-id` header
- Error body v2: `{ error: { statusCode, code, message, details? }, requestId }`
- GET /health body unchanged
## Tests passed (evidence)
```
npm run lint:boundaries -> Boundary check OK: 16 file(s) checked
npm run lint -> OK
npm run typecheck -> exit 0
npm run build -> exit 0
npm test -> 6 files passed, 2 skipped (integration, no DB in unit run); 23 passed | 6 skipped
live smoke (PORT=3998):
GET /health -> 200, x-request-id: 65b38aee-..., body {"status":"ok"}
GET /nope -> 404 envelope v2 with requestId matching log line
POST /health bad json -> 400; log code FST_ERR_CTP_INVALID_JSON_BODY with same requestId as response
server logs: one "request completed" JSON line per request carrying requestId
```
## Known limitations
- Fastify 4xx parse-error messages are exposed as-is (e.g. "Unexpected token"); they carry no internals and are standard.
- Validation hook is exercised via a test-only route (/__test/echo registered in composition test); first production consumer arrives with F-005.
## Follow-up work
- F-004 config/flags will centralize LOG_LEVEL handling.
- F-005 identity first real parseJson consumer.

View File

@@ -0,0 +1,35 @@
{
"feature_id": "F-003",
"agent": "leader",
"verdict": "APPROVED",
"summary": "F-003 closed. HTTP foundation in place: request_id everywhere (generated or sanitized-propagated), JSON structured logs correlated per request, stable error envelope v2, explicit validation hook. All gates APPROVED, verify.sh exit 0.",
"gates": {
"reviewer": "APPROVED (reviewer.json)",
"security": "APPROVED (security.json)",
"qa": "APPROVED (qa.json)",
"verify_sh": "exit 0"
},
"deliverables": [
"src/infrastructure/logging/logger.ts (pino factory, injectable destination)",
"src/shared/errors.ts (AppError + error envelope v2)",
"src/shared/http-input.ts (parseJson zod hook)",
"src/app/build-app.ts (genReqId sanitization, onRequest/onResponse hooks, error+notFound handlers)",
"src/infrastructure/http/server.ts (logger-based startup)",
"12 new tests across shared + app composition; README HTTP contract section",
"spec/tech.md pino+zod justification"
],
"process_notes": [
"Doc stage bounced to build to write README (project/ gated to build/implementer/running). This is the correct guardrail behavior."
],
"next_feature_hint": "F-004 (config/flags) only depends on F-001; F-005 identity needs F-002+F-003, both now done -> F-005 unblocked",
"evidence": [
"work/artifacts/F-003/architect.md",
"work/artifacts/F-003/implementer.md",
"work/artifacts/F-003/reviewer.json",
"work/artifacts/F-003/security.json",
"work/artifacts/F-003/qa.json",
"work/artifacts/F-003/documenter.md",
"./scripts/verify.sh exit 0 at close"
],
"timestamp": "2026-08-14T20:18:00Z"
}

View File

@@ -0,0 +1,40 @@
{
"feature_id": "F-003",
"agent": "qa",
"verdict": "APPROVED",
"summary": "All 5 acceptance criteria verified with fresh live probes and full suites.",
"traceability": [
{
"criterion": "AC1: every response carries request_id",
"test": "live curl: GET /health (200) and GET /missing (404) both returned x-request-id UUID headers",
"result": "PASS"
},
{
"criterion": "AC2: every request log line carries the same request_id",
"test": "grep of server JSON logs: each live request_id appears exactly once, in its 'request completed' line",
"result": "PASS"
},
{
"criterion": "AC3: invalid request -> stable error envelope shape",
"test": "live 404 and malformed-JSON 400 bodies share { error: { statusCode, code, message }, requestId }; schema-invalid case covered by unit test (VALIDATION_ERROR + field details)",
"result": "PASS"
},
{
"criterion": "AC4: internal stack traces never leak",
"test": "live bodies contain zero occurrences of 'stack'; unit test proves 500 handler hides thrown message and stack while logging them server-side",
"result": "PASS"
},
{
"criterion": "AC5: verify.sh green",
"test": "./scripts/verify.sh",
"result": "PASS (exit 0)"
}
],
"regressions": "PASS - unit 23 passed | 6 skipped; F-002 integration 6 passed; health endpoint body unchanged",
"evidence": [
"live probes at PORT=3997 with fresh dist build",
"npm test / test:integration green",
"./scripts/verify.sh exit 0"
],
"timestamp": "2026-08-14T20:16:00Z"
}

View File

@@ -0,0 +1,27 @@
{
"feature_id": "F-003",
"agent": "reviewer",
"verdict": "APPROVED",
"summary": "HTTP foundation matches specs/F-003 DESIGN.md. Explicit hooks, sanitized id propagation, stable envelope v2, no stack leak. No regressions: F-001/F-002 suites fully green including database integration tests.",
"checks": {
"design_conformance": "PASS: logger factory, AppError/envelope v2, parseJson hook, genReqId + hooks + handlers all exactly as designed; logger: false on Fastify prevents double logging",
"security_of_propagation": "PASS: strict regex ^[A-Za-z0-9._-]{1,128}$ before trusting incoming x-request-id; tested against newline injection and oversized values",
"no_magic": "PASS: validation is an explicit parseJson call; no decorators/annotations; hooks are plain readable functions in the composition root",
"test_coverage": "PASS: 12 new tests map 1:1 to specs/F-003 TESTS.md; test-only route /__test/echo exercises the hook end-to-end without polluting production API",
"regression": "PASS: full unit suite 23 passed; F-002 integration suite 6 passed; envelope change verified additive"
},
"findings": [
{
"severity": "info",
"note": "Fastify 4xx error codes (e.g. FST_ERR_CTP_INVALID_JSON_BODY) surface in the envelope code field. Public codes, no internals; acceptable and useful for clients."
}
],
"evidence": [
"npm run lint / typecheck / lint:boundaries (16 files) -> exit 0",
"npm test -> 6 files passed, 23 passed | 6 skipped",
"npm run test:integration -> 2 files, 6 passed (no F-002 regression)",
"live smoke at build stage: x-request-id header on 200/404/400, requestId correlated in JSON logs",
"files reviewed: src/app/build-app.ts, src/shared/errors.ts, src/shared/http-input.ts, src/infrastructure/logging/logger.ts, src/infrastructure/http/server.ts, both test suites"
],
"timestamp": "2026-08-14T20:12:00Z"
}

View File

@@ -0,0 +1,27 @@
{
"feature_id": "F-003",
"agent": "security",
"verdict": "APPROVED",
"summary": "Security gate passed. Header propagation sanitized against injection, no stack/internal leakage, log injection neutralized by JSON serialization, zero audit vulnerabilities with pino+zod.",
"checks": {
"dependencies": "PASS: npm audit -> 0 vulnerabilities; pino/zod justified in spec/tech.md",
"header_injection": "PASS: incoming x-request-id accepted only under ^[A-Za-z0-9._-]{1,128}$; newline and 129-char payloads replaced by UUID (regression tests present)",
"response_leakage": "PASS: 5xx path forces generic 'Internal Server Error'; body assertions confirm absence of thrown message and stack; 4xx exposes only safe messages/codes",
"log_injection": "PASS: requestId is pre-sanitized; url/method serialized via pino JSON encoding which escapes control characters",
"dangerous_patterns": "PASS: no eval / new Function / child_process in src"
},
"findings": [
{
"severity": "info",
"note": "Fastify 4xx codes exposed in envelope (e.g. FST_ERR_CTP_INVALID_JSON_BODY) are public framework codes; no internal state disclosed."
}
],
"evidence": [
"npm audit -> found 0 vulnerabilities",
"grep eval|new Function over src -> none",
"grep x-request-id non-test usages -> only constant + sanitized generator path",
"5xx generic guard verified at build-app.ts lines 68/75",
"test 'internal errors never leak message or stack to the client' passes"
],
"timestamp": "2026-08-14T20:14:00Z"
}