Implements ADR-0012 phase 1, BFF side. The SPA wiring is a
separate phase-2 PR.
Runtime libraries added (production deps):
- nestjs-pino, pino, pino-http structured JSON logging
- nestjs-cls request-scoped context
- @opentelemetry/api / sdk-node / resources / semantic-conventions
- @opentelemetry/exporter-trace-otlp-proto OTLP HTTP/Protobuf
- @opentelemetry/instrumentation-{http,express,nestjs-core,pg,
ioredis,pino} curated, no `auto-instrumentations-
node` mass-import (anti-bricolage)
Dev dep: pino-pretty (gated by NODE_ENV).
Code:
- apps/portal-bff/src/observability/tracing.ts — OTel `NodeSDK`
bootstrap. Documents the load-order constraint inline (must be
the very first import of `main.ts`). Exports nothing — pure
side-effect file.
- apps/portal-bff/src/observability/observability.module.ts —
composes ClsModule (UUID per request stored as `request_id`)
and LoggerModule (pino-pretty in dev / raw JSON in prod,
LOG_LEVEL env-driven, `/health` excluded from auto-logging).
- apps/portal-bff/src/health/{health.controller,health.module}.ts
— `GET /api/health` returning `{status, uptimeSeconds, service,
version}`. Cheap liveness only — no DB / Redis check; that
belongs to a future `/readiness` once dependencies have a
readiness story.
- apps/portal-bff/src/config/check-database-url.ts — fail-fast
validator called from main.ts before NestFactory boots. Catches
the same family of bug that bit pgweb in #63: a literal special
char in POSTGRES_PASSWORD that needs URL-encoding in
DATABASE_URL. Prisma requires a URL string, so we cannot use
discrete CLI flags here — early validation + clear error message
is the v1 mitigation. Six unit tests cover happy path, missing
URL, wrong scheme, encoded special chars, literal `@` in
password, malformed URL.
Wiring:
- main.ts now `import`s `./observability/tracing` as its first
line, calls `assertDatabaseUrl()`, then bootstraps Nest with
`app.useLogger(app.get(Logger))` from nestjs-pino with
`bufferLogs: true` so early-bootstrap lines are not lost.
- app.module.ts imports ObservabilityModule first, then the
PrismaModule, then HealthModule.
- .env.example (BFF and infra/local) document the URL-encoding
constraint on POSTGRES_PASSWORD with the exact char-by-char
encoding table.
Trace ↔ log correlation is automatic via
`@opentelemetry/instrumentation-pino`: every Pino record gets
`trace_id` / `span_id` injected from the active OTel context.
No CLS gymnastics needed for that specific concern.
ADR-0012 §Confirmation rewritten to clearly distinguish what
landed in this PR (phase 1) from what is wired as the
corresponding feature ADRs ship (CLS keys for session/user/
audience, LOG_USER_ID_SALT, redact list, custom spans, SPA-side
SDK, full integration tests, prod Collector config).
Verified locally:
- pnpm exec nx run-many -t lint test build → 8 projects green,
4 test suites for portal-bff, 9 unit tests pass.
- `pnpm nx serve portal-bff` boots, `curl /api/health` returns
the expected JSON; logs are pretty-printed JSON one-liners on
stdout in dev.
- `pnpm audit --audit-level=moderate` reports 0 vulnerabilities.
Add Prisma 7 + nestjs-prisma. The schema lives at
apps/portal-bff/prisma/schema.prisma with provider postgresql; the new
prisma-client generator (Prisma 7 default) outputs the typed client to
apps/portal-bff/generated/prisma/ which is gitignored.
apps/portal-bff/src/app/app.module.ts imports PrismaModule.forRoot
({ isGlobal: true }) so PrismaService is injectable across the BFF
without per-module imports.
apps/portal-bff/.env.example documents DATABASE_URL with a local-dev
default, plus a forward list of env vars introduced by upcoming phases
and ADRs (auth, sessions, MFA, observability, audit, downstream APIs)
- catalog reference, not implementation. The actual .env stays
gitignored at both repo root and app levels.
prisma.config.ts (Prisma 7's TypeScript config) is committed; it loads
DATABASE_URL via dotenv. Schema and migrations paths are pinned to
prisma/ relative to the bff app.
PostgreSQL provisioning, RLS policies for the dual-audience design,
the dedicated audit schema with role grants (audit_owner / audit_writer
/ audit_reader / audit_archiver per ADR-0013), and column-level
encryption for L3-scoped data are out of scope of this commit -
they belong with the future on-prem infrastructure ADR.
The strict-TS option noPropertyAccessFromIndexSignature: true (set in
tsconfig.base.json per ADR-0004) forbids dot-notation access on index
signatures. process.env is typed as { [key: string]: string | undefined }
so process.env.PORT must be written process.env['PORT']. The Nx
generator wrote the dot form by default; fix to comply with the
project's strict-TS bar.
Touched: portal-bff main.ts and the three portal-bff-e2e support files
(global-setup, global-teardown, test-setup).
Add the @nx/angular, @nx/nest, @nx/vite, @nx/eslint plugins, then
generate the two apps. Adjust the empty-template tsconfig.base.json
to be Angular-compatible (drop project references and customConditions
that the empty-template defaults to but Angular doesn't support; keep
the strict-TS extensions from ADR-0004).
apps/portal-shell (Angular 21):
- standalone APIs, routing, SCSS, esbuild
- vitest-angular as unitTestRunner, playwright for e2e
- strict mode
- tags scope:portal-shell, type:app
- app.config.ts wired with provideZonelessChangeDetection() per
ADR-0004 (Angular 21 + Nx 22 generates without zone.js by default)
apps/portal-bff (NestJS 11):
- Express adapter (default per ADR-0005)
- Jest as unitTestRunner
- tags scope:portal-bff, type:app
- main.ts wired with a global ValidationPipe configured
whitelist + forbidNonWhitelisted + transform per ADR-0005
- Phase-2 security additions (helmet, CORS, sessions, CSRF, rate
limit, auth guards, error filter) deferred to their respective
ADRs - placeholder comment in main.ts
Workspace dependencies: class-validator + class-transformer added
(required by NestJS ValidationPipe at runtime). Nx-generated
.gitignore additions (.angular, __screenshots__) merged into ours.
.vscode/extensions.json and launch.json added by Nx are kept (do not
override our existing settings.json).