--- status: accepted date: 2026-05-10 decision-makers: R&D Lead tags: [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`](../../apps/portal-bff/src/config/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`](../../apps/portal-bff/src/audit/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 contributor** — `cp .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. - **`` injected in `index.html`** — the deploy step rewrites the `` content; the SPA reads `document.querySelector('meta[name=config]').content` at boot. - **esbuild `define` substitution** — `process.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 `` 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 `_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`, `_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. #### `` 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 - Angular `fileReplacements`: https://angular.dev/tools/cli/build#configuration-environment-specific - Twelve-factor config: https://12factor.net/config - Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0010](0010-session-management-redis.md) (sessions), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTLP endpoint env var), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (audit pool, this ADR formalises its split), [ADR-0014](0014-downstream-api-access-obo-pattern.md) (per-downstream env vars).