Files
apf_portal/docs/decisions/0018-environment-configuration-strategy.md
T
julien 4476cbb518
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 1m36s
CI / perf (push) Successful in 3m33s
docs(decisions): add ADR-0018 — environment configuration strategy (#80)
## Summary

Records the per-environment config strategy for both apps. ADR-only — no code changes; the implementation is anchored in §Confirmation against the next feature work that touches per-environment values.

## What lands

[**ADR-0018 — Environment configuration**](docs/decisions/0018-environment-configuration-strategy.md):

- **SPA**: Angular `environment.ts` + project.json `fileReplacements`. Build-time substitution; no runtime config fetch (rejected for the LCP/TTFB cost it would add and the deploy-time HTML rewrite that the alternatives need). Concretely cleans up the hard-coded URLs we left in `observability/tracing.ts` and `home-status.service.ts` ("hard-coded for v1 — env-config when it lands" comments).
- **BFF**: keep `process.env` + small per-key boot-time validators (the shape `apps/portal-bff/src/config/check-database-url.ts` already follows). `@nestjs/config` rejected as too heavy for the current key count; `zod` not justified yet.
- **Audit log connection**: formalises the `AUDIT_DATABASE_URL` split that ADR-0013 §"Wired as features land" already pointed at. When set, the `AuditModule` instantiates a second Prisma client with `audit_writer`-only credentials (defense in depth); when unset, the dev fallback (shared pool + `SET LOCAL ROLE audit_writer` per transaction) keeps working. A boot-time `UPDATE`-rejection self-test runs against the dedicated pool when configured — refuses to start if the pool can mutate the audit table.

The §Confirmation block cross-references the env-var-as-boot-gate items already pre-figured in ADR-0009 / ADR-0010 / ADR-0012 / ADR-0013 / ADR-0014, so the validator pattern is the single landing place when those features ship.

## What does NOT change in this PR

- No `apps/portal-shell/src/environments/*.ts` files yet — landed alongside the next feature that actually needs per-environment values.
- No `AUDIT_DATABASE_URL` validator in BFF — same; lands when the second pool is wired.
- No CLAUDE.md restructuring; just one extra bullet under the Architecture summary referencing ADR-0018.

## Doc updates

- `docs/decisions/README.md` index — new row for ADR-0018.
- `CLAUDE.md` Architecture summary — one-line reference to ADR-0018 between the perf-budget and local-quality-gates entries.

## Test plan

- [ ] CI green on this PR (`format:check`).
- [ ] ADR-0018 renders in the doc index with the right tags (`frontend`, `backend`, `infrastructure`, `process`) and 2026-05-10 date.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #80
2026-05-10 04:58:02 +02:00

16 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-10 R&D Lead
frontend
backend
infrastructure
process

Environment configuration — Angular environment.ts, BFF env vars, audit pool split

Context and Problem Statement

Both apps need per-environment configuration values — service endpoints, observability targets, feature flags, secrets — but the runtimes are radically different:

  • The BFF is Node and naturally reads process.env. There is already a .env.example catalogue (see apps/portal-bff/.env.example) and a startup validator for DATABASE_URL (check-database-url.ts).
  • The SPA is a pre-built static bundle served to the browser. There is no process.env at runtime, and the bundle is identical across dev / staging / production unless we build per-environment artefacts.

Two questions need a recorded answer before they bite the next time a feature lands:

  1. How does the SPA learn its environment-specific endpoints? Today the OTLP collector URL and the BFF API URL are hard-coded in apps/portal-shell/src/observability/tracing.ts and apps/portal-shell/src/app/pages/home/home-status.service.ts. That works for localhost development, breaks the moment we ship to staging or production.
  2. How does the BFF connect to Postgres for the audit log? ADR-0013 specifies the audit log uses a distinct Postgres role (audit_writer). v1 of the audit module (audit.service.ts) cuts a single connection from the shared DATABASE_URL pool and runs SET LOCAL ROLE audit_writer per transaction. ADR-0013 §"Wired as features land" already lists a separate AUDIT_DATABASE_URL pool as the production-grade hardening; this ADR records the actual contract so the implementation is unambiguous when it ships.

Decision Drivers

  • Twelve-factor compliance — config in the environment, not in the build artefact, wherever feasible.
  • No surprise drift between dev and prod — same mechanism, different values; not "dev uses X, prod uses Y".
  • Simplicity for the contributorcp .env.example .env, edit, run. No config-discovery folklore.
  • Prod-faithful security — the BFF connection that writes audit rows must not be the same connection that reads / writes the rest of the database. Defense in depth.
  • Stability bar — recognised, battle-tested patterns. No bricolage.

Considered Options

SPA configuration mechanism

  • Angular environment.ts + fileReplacements — a src/environments/environment.ts file holds the dev defaults; per-environment files (environment.staging.ts, environment.prod.ts) are swapped in at build time via the project.json's fileReplacements. Idiomatic Angular, supported by @angular/build. (Chosen.)
  • Runtime fetch from /config.json — the SPA fetches a JSON file from the server at boot, before any feature code runs. Late-binding, single artefact across environments.
  • <meta name="config" content="…"> injected in index.html — the deploy step rewrites the <meta> content; the SPA reads document.querySelector('meta[name=config]').content at boot.
  • esbuild define substitutionprocess.env.X references in source are textually replaced at build time; equivalent to environment.ts but less Angular-idiomatic.

BFF audit-log connection

  • Single DATABASE_URL + SET LOCAL ROLE audit_writer per transaction — current v1. One Prisma client, one pool. Role switch wraps each INSERT.
  • Separate AUDIT_DATABASE_URL pool with a login user that has audit_writer membership only — second Prisma client, second pool. The connection cannot perform anything except INSERT INTO audit.events. (Chosen for production hardening; v2 of the audit module.)
  • Single connection that runs as audit_writer permanently — everything goes through the audit-writer role. Wrong: the BFF needs many more privileges than audit_writer for the rest of its work.

BFF env-var loading

  • process.env directly + a small validator at boot — current pattern. Plain Node, no dep. Fail loud on missing or malformed values. (Chosen, kept.)
  • @nestjs/config — Nest-idiomatic config module. Schema validation, hierarchical lookup, .env loading. Heavier surface for what amounts to ~10 keys today.
  • zod schema over process.env — typed validation, narrow surface. Adds a runtime dep.

Decision Outcome

SPA — Angular environment.ts with build-time fileReplacements

  • apps/portal-shell/src/environments/environment.ts is the default (dev), checked in. Holds two keys today: bffApiBaseUrl and otlpEndpoint. Both default to http://localhost:3000/api and http://localhost:4318/v1/traces respectively.
  • apps/portal-shell/src/environments/environment.staging.ts and apps/portal-shell/src/environments/environment.prod.ts ship later — checked in, since they target real, public, non-secret URLs (the BFF and Collector are public services from the SPA's perspective).
  • The Angular build target's production configuration in project.json adds a fileReplacements entry that swaps environment.ts for environment.prod.ts; a future staging configuration does the same with environment.staging.ts. Build script: nx build portal-shell --configuration=production continues to do the right thing without code changes.
  • Anything secret (none today on the SPA side, by design) is not in environment.ts. The SPA is a static bundle; secrets baked in are public secrets. If a feature ever requires a per-user / per-tenant secret, it lives in the session payload (per ADR-0010), never in the bundle.

Concrete migration of today's hard-coded values:

Today After
'http://localhost:4318/v1/traces' in observability/tracing.ts environment.otlpEndpoint
'http://localhost:3000/api/health' in pages/home/home-status.service.ts ${environment.bffApiBaseUrl}/health
propagateTraceHeaderCorsUrls: [/^http:\/\/localhost:3000\/.*/] derived from environment.bffApiBaseUrl

The runtime-fetch and <meta> alternatives are rejected: same artefact across environments is appealing but introduces a startup-blocking fetch (RUM cost) and a deploy-time HTML-rewrite step (extra moving piece). Build-time replacement is cheaper, idiomatic, and fits our infrastructure-as-code approach to deploy artefacts.

BFF — process.env + boot-time validator, with explicit AUDIT_DATABASE_URL split

  • BFF config keys remain plain process.env['X'] reads, validated at boot in small dedicated files (the same shape as apps/portal-bff/src/config/check-database-url.ts). @nestjs/config is rejected: too much surface for a small key set; Nest's DI is not a real win when config is read once at boot.
  • The validator family grows as feature ADRs land — LOG_USER_ID_SALT once auth is wired (ADR-0009 / ADR-0013), SESSION_ENCRYPTION_KEY and SESSION_*_TIMEOUT_SECONDS once sessions land (ADR-0010), OBO_CACHE_ENCRYPTION_KEY and <SERVICE>_API_BASE_URL once downstream wiring lands (ADR-0014). Each of those ADRs already mentions "BFF refuses to start without …" — that contract is implemented in this validator pattern.
  • AUDIT_DATABASE_URL is the production split: when set, it overrides the shared DATABASE_URL for the audit module's Prisma client only. The connection string points at the same Postgres instance but uses a login user that is a member of audit_writer only — no superuser, no public-schema CRUD, just INSERT INTO audit.events. When not set (the dev default), the audit module falls back to the shared client + SET LOCAL ROLE audit_writer per-transaction (current v1 behaviour). The fallback explicit choice keeps the dev experience one-step (single .env, no role-membership setup beyond the existing init.sql), while guaranteeing production gets the harder isolation.
  • A boot-time self-test asserts the split when AUDIT_DATABASE_URL is set: a deliberate UPDATE audit.events SET … is issued and is expected to fail with permission denied. If it succeeds, the BFF refuses to start — the audit log is no longer append-only.
  • Renamed from the previous .env.example placeholder AUDIT_DATABASE_URL (separate creds, role 'audit_writer') — the contract is now the formal one above.

Per-environment file structure

Both apps converge on the same naming for clarity:

apps/portal-shell/src/environments/
  environment.ts              ← dev, checked in
  environment.staging.ts      ← staging, checked in (later)
  environment.prod.ts         ← prod,  checked in (later)

apps/portal-bff/.env.example  ← single source-of-truth template, checked in
apps/portal-bff/.env          ← per-developer values, git-ignored

Production BFF env values are managed by the deploy platform's secret manager (per the future on-prem infrastructure ADR — phase 3b); the .env.example stays the catalogue.

Consequences

  • Good, because every env-specific value has an obvious home and an obvious change procedure (edit environment.{env}.ts for SPA, edit secret-manager / .env for BFF).
  • Good, because the SPA stays a static bundle — no runtime config-fetch latency, no extra deploy step beyond what nx build already does.
  • Good, because the audit-pool split is now formalised. The migration from v1 (SET LOCAL ROLE) to v2 (separate pool) is a config change in production (set AUDIT_DATABASE_URL) and a small code change in audit.module.ts (instantiate a second client when the env var is set). Both are bounded.
  • Good, because the boot-time validator pattern keeps the BFF crashing early on misconfig — never a request-time surprise.
  • Bad, because per-environment environment.ts files mean the SPA does not produce a single artefact reusable across environments. Every promotion through the deploy pipeline rebuilds. Acceptable: build is fast (~2 s) and the alternative (runtime config) was rejected on its own merits.
  • Bad, because Angular's fileReplacements is a build-time mechanism that hides the substitution from the source — the contributor reading environment.ts does not see the prod values without opening environment.prod.ts and the project.json target. Mitigated by keeping all three files side by side in src/environments/.
  • Neutral, because BFF config still uses raw process.env — no fancy schema validation. Acceptable while the key set stays under ~30; revisit with zod if it grows substantially.

Confirmation

Wired as part of the next feature work that touches per-environment config:

  • apps/portal-shell/src/environments/environment.ts (and per-environment siblings) created; bffApiBaseUrl and otlpEndpoint exposed.
  • The hard-coded URLs in observability/tracing.ts and pages/home/home-status.service.ts consume the environment object instead.
  • apps/portal-shell/project.json production configuration declares the fileReplacements swap; a staging configuration follows when the staging build target is set up.
  • BFF gains a apps/portal-bff/src/config/audit-database-url.ts validator that reads AUDIT_DATABASE_URL (optional) and exposes a typed config; the AuditModule instantiates a second Prisma client when the var is set.
  • Boot-time UPDATE-rejection self-test runs against the audit-writer pool when configured.
  • apps/portal-bff/.env.example documents AUDIT_DATABASE_URL per the contract above (replacing the placeholder line).

Wired as the corresponding feature ADRs ship (already documented in their own §Confirmation, repeated here for indexing):

  • LOG_USER_ID_SALT mandatory at boot — auth ADR-0009 / sessions ADR-0010 / observability ADR-0012.
  • SESSION_ENCRYPTION_KEY, SESSION_IDLE_TIMEOUT_SECONDS, SESSION_ABSOLUTE_TIMEOUT_SECONDS — sessions ADR-0010.
  • MFA_FRESHNESS_SECONDS — MFA ADR-0011.
  • OBO_CACHE_ENCRYPTION_KEY, BFF_JWKS_PRIVATE_KEY_PATH, BFF_JWKS_KID, <SERVICE>_API_BASE_URL — downstream API ADR-0014.

Pros and Cons of the Options

SPA configuration mechanism

Angular environment.ts + fileReplacements (chosen)

  • Good, because idiomatic Angular — every Angular contributor recognises it.
  • Good, because zero runtime cost, zero extra deploy step.
  • Good, because the values are visible in source (one file per environment, side by side).
  • Bad, because per-environment artefact (rebuild on promotion). Acceptable given build is fast.

Runtime fetch from /config.json

  • Good, because single artefact across environments (build once, deploy everywhere).
  • Bad, because adds a startup-blocking HTTP fetch (RUM cost on LCP/TTFB budgets per ADR-0017).
  • Bad, because a runtime-config error means the SPA cannot start — discoverable only at first user load.

<meta name="config" content="…"> injected at deploy

  • Good, because also single artefact.
  • Bad, because the deploy platform must rewrite index.html as part of release. Extra moving piece, easy to forget on a new platform.
  • Bad, because mixing config and HTML hurts cacheability of index.html.

esbuild define substitution

  • Good, because also build-time, also fast.
  • Bad, because circumvents Angular's expected pattern; future contributors look for environment.ts first. Avoid the surprise.

BFF audit-log connection

Separate AUDIT_DATABASE_URL pool (chosen for prod, opt-in)

  • Good, because hard runtime isolation — the audit-writing connection cannot perform anything else.
  • Good, because a SQL injection on a non-audit code path cannot reach the audit log.
  • Bad, because second connection pool to operate (sized, monitored, drained on shutdown). Bounded operational cost.

Single DATABASE_URL + SET LOCAL ROLE (current v1)

  • Good, because one pool, one set of credentials. Simpler dev story.
  • Bad, because the BFF connection retains all its other privileges; a runtime exploit could RESET ROLE and bypass the contract. Acceptable for v1, mitigated by Postgres role grants ensuring UPDATE / TRUNCATE are denied even to the underlying superuser at runtime — but the BFF login user could still read business data the audit-only connection wouldn't.

Single connection that runs as audit_writer permanently

  • Bad, because the BFF's other features need many more privileges than audit_writer. Doesn't fit.

BFF env-var loading

process.env + boot-time validator (chosen)

  • Good, because zero dep, fast, clear failure.
  • Bad, because no schema declaration in one place — validation is per-file.

@nestjs/config

  • Good, because Nest-idiomatic, hierarchical lookup.
  • Bad, because heavy for our key count, adds DI ceremony, hides the simplicity of "just read process.env".

zod schema

  • Good, because typed validation, clear schema declaration.
  • Bad, because runtime dep for an at-boot check that does not need to be re-run.

More Information