docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope)
CI / scan (pull_request) Successful in 2m11s
CI / commits (pull_request) Successful in 2m11s
CI / check (pull_request) Successful in 2m20s
CI / a11y (pull_request) Successful in 1m5s
CI / perf (pull_request) Successful in 2m32s

Documents the security middleware stack shipped across the five most
recent BFF PRs (#115, #117, #120, #122, #123) as a single MADR ADR.
Today the rationale lives in code comments and PR descriptions; the
next contributor reaching for `csurf`, cookie-only CSRF, or a
hardcoded localhost CORS fallback didn't have one place to read why
those are wrong here.

Contents:
  - Response envelope: { error: { code, message, traceId } }, single
    contract across Nest's filter and raw middlewares via the
    errorResponse() helper. Status-code mapping documented. 500s
    never leak the underlying exception.
  - CSRF session-bound double-submit, not cookie-vs-header. The
    cookie is the SPA's read-only mirror; the source of truth is
    req.session.csrfToken — subdomain-takeover injection can't
    bypass.
  - CORS allowlist env-driven, mandatory, no fallback. Catches the
    "works in dev breaks in prod" misconfiguration at boot.
  - Rate limit: sessionID-then-IP bucket key, 10/min auth +
    120/min general, /api/health skipped, in-memory v1 store.
  - Helmet defaults + three overrides (HSTS prod-only, COR
    cross-origin, CSP prod-only) with code-anchored justifications.

Considered options mirror the actual debate — each rejected
alternative has its "Bad, because" line so a reader sees why we
didn't go that way. Each Decision Outcome line cross-references
the file that enforces it.

Scope is the IMPLEMENTATION-level baseline. The STRATEGIC baseline
(ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off)
remains paused per CLAUDE.md. When it lands it confirms or
supersedes pieces of 0021; the ADR is explicit about which choices
are tactical.

Index entry in docs/decisions/README.md updated in the same commit
per the project convention.

CLAUDE.md §"Repository status" still says the workspace is "not
yet bootstrapped" and refs ADRs up to 0020 — that paragraph is
stale (22 ADRs in place, project fully scaffolded). Worth a small
dedicated docs PR; out of scope here.
This commit is contained in:
Julien Gautier
2026-05-13 23:41:51 +02:00
parent 0e6c114ba7
commit e2fae02731
2 changed files with 267 additions and 22 deletions
@@ -0,0 +1,244 @@
---
status: accepted
date: 2026-05-13
decision-makers: R&D Lead
tags: [security, backend]
---
# Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope
## Context and Problem Statement
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) noted that the BFF in v1 ships with a minimal CORS allowlist and that the security-hardening stack — helmet, real allowlist, CSRF, rate limiting, structured error filter — would land "with the phase-2 security ADR". That phase-2 security ADR (the **baseline**, in the sense of "which ASVS level does the project target, which adjacent frameworks apply") is **paused** until the RSSI clarifies the reference level and the regulatory perimeter (HDS, GDPR, possibly PCI DSS / NIS 2).
In the meantime, the BFF accumulated five PRs that landed concrete security middleware (#115 absolute-timeout, #117 SPA route guard + credentials interceptor, #122 helmet + CORS + CSRF, #123 rate-limit + structured error filter). Each PR captured its own rationale, but the choices live in code comments and PR descriptions — there is no single piece of documentation that says "this is why CSRF is session-bound", "this is the response envelope contract", "this is the rate-limit bucket strategy". A future contributor reaching for `csurf` or `cookie-only CSRF` because they don't know better is the kind of slip this ADR is meant to prevent.
This ADR records **the concrete implementation choices** made in those five PRs. It is deliberately one notch below the strategic baseline (ASVS level, regulatory framing, RSSI sign-off) — that one lands separately when the RSSI input arrives, and this one becomes its v1 reference for "what the BFF actually does today".
## Decision Drivers
- Capture rationale while the memory is fresh — most of these choices are non-obvious without their context.
- Provide a single landing point for a security review (internal or external).
- Single response contract for every error so the SPA has one shape to parse and so 5xx never leak internals.
- Defense in depth: each middleware addresses a distinct attack class, and removing one shouldn't open the others.
- Sensible-by-default in production, fail-loudly-on-misconfiguration at boot.
## Considered Options
### Response envelope on errors
- **`{ error: { code, message, traceId } }` shared by Nest's exception filter and raw middleware (chosen).**
- Per-route ad-hoc shapes (the pre-PR baseline).
- Nest's default `{ statusCode, message, error }` at the top level.
### CSRF strategy
- **Session-bound double-submit (server-side session token + JS-readable cookie + `X-CSRF-Token` header, comparison against the session token, not the cookie) (chosen).**
- Pure cookie-vs-header double-submit (no server-side storage).
- Synchronizer token pattern (server-side token, rendered into HTML — N/A, the BFF doesn't render HTML).
- `csurf` package.
### CORS allowlist
- **Mandatory env-driven allowlist via `CORS_ALLOWED_ORIGINS`, parsed and validated at boot, no fallback (chosen).**
- Hardcoded localhost fallback with env override.
- Open `*` policy gated by `credentials: false`.
### Rate limiting bucket key
- **`sessionID` when authenticated, remote IP otherwise (chosen).**
- IP only.
- User id (Entra `oid`) when authenticated.
### Rate limiting tiers
- **Two tiers via dynamic `max` per request: 10/min strict on `/auth/login` + `/auth/callback`, 120/min general (chosen).**
- One uniform limit.
- Per-route limits via separate `rateLimit()` instances.
### Rate limiting store
- **In-memory (single-instance default of `express-rate-limit`) (chosen for v1).**
- Redis-backed (`rate-limit-redis`).
### Helmet CSP
- **`helmet()` defaults in production, `contentSecurityPolicy: false` in development (chosen).**
- Custom CSP tailored to the BFF.
- Disabled everywhere (Nest's default has no CSP either).
## Decision Outcome
The BFF runs five middleware in this order, mounted in `apps/portal-bff/src/main.ts`:
1. **`StructuredErrorFilter`** (registered via `app.useGlobalFilters` at the top of `bootstrap()`) — normalises every NestJS exception to `{ error: { code, message, traceId } }`. Source of truth for the response envelope.
2. **`helmet()`** — security headers. Three overrides:
- `hsts` only in production (dev runs on plain HTTP),
- `crossOriginResourcePolicy: 'cross-origin'` (SPA on its own origin reads JSON from the BFF),
- `contentSecurityPolicy: false` outside production (the BFF returns JSON; the default CSP triggers noisy `connect-src` violations in devtools without protecting anything we care about on JSON responses).
3. **CORS allowlist**`CORS_ALLOWED_ORIGINS` env-driven, mandatory at boot. Parsed + validated (`http(s):` only, bare origins, no path/query). No localhost fallback. `X-CSRF-Token` declared in `allowedHeaders` alongside `Content-Type / Accept / Authorization / traceparent / tracestate`.
4. **Rate limiting** (`express-rate-limit`) — dynamic `max` per request: 10/min on the auth-flow entry routes, 120/min everywhere else, `/api/health` skipped. Bucket key = session id when `req.session.user` is set, else remote IP. In-memory store; v2 swaps for `rate-limit-redis` when we scale BFF horizontally. Response = same envelope as the filter (`code: 'rate_limited'`).
5. **CSRF middleware** (custom; not `csurf`, which is unmaintained) — double-submit, **session-bound**: token minted at session creation in `/auth/callback`, stored on `req.session.csrfToken` AND mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). Request middleware compares the `X-CSRF-Token` header against the **session-stored** token with `crypto.timingSafeEqual`. Skips safe methods (`GET / HEAD / OPTIONS`), unauthenticated requests, and the auth entry routes that mint the token (`/api/auth/login`, `/api/auth/callback`).
The order is intentional:
- The filter is registered first so it catches errors from any subsequent middleware initialisation.
- helmet → CORS → … so headers are set on every response, including pre-flight `OPTIONS`.
- Rate limit → CSRF: rate-limit rejects without paying the CSRF comparison cost; on an already-blocked client we don't burn cycles.
- Both raw middlewares (rate-limit, CSRF) use the shared `errorResponse()` helper exported by `StructuredErrorFilter` so the wire format is identical whether the response came through the filter or through a middleware short-circuit.
### Response envelope contract
```json
{
"error": {
"code": "csrf",
"message": "CSRF token missing or invalid",
"traceId": "abc123…"
}
}
```
- **`code`** is the SPA's switch-on key. Stable strings: `unauthenticated`, `forbidden`, `not_found`, `csrf`, `rate_limited`, `bad_request`, `internal`, `service_unavailable`, and any custom code an `HttpException` declares via its response object (`new UnauthorizedException({ code: 'unauthenticated', message: '…' })`).
- **`message`** is safe, human-readable text. 5xx exceptions never put their internal message in the response — the full exception goes to the Pino `error` log line via the `err: exception` field; the response body says "Internal server error".
- **`traceId`** is the active OTel trace id (or `null`). Lets the user paste it into a support ticket and have ops correlate against Jaeger + audit rows + Pino lines in one query.
### Configuration (env-driven)
| Variable | Required | Purpose |
| ---------------------------- | ---------------- | --------------------------------------------------------------------------------------- |
| `CORS_ALLOWED_ORIGINS` | **yes** | Comma-separated `scheme://host[:port]` list. BFF refuses to boot if absent / malformed. |
| `RATE_LIMIT_PER_MINUTE` | no — default 120 | General bucket ceiling. |
| `RATE_LIMIT_AUTH_PER_MINUTE` | no — default 10 | Stricter bucket for `/auth/login` + `/auth/callback`. |
`LOG_USER_ID_SALT`, `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY` were promoted to mandatory at boot in earlier PRs; this ADR doesn't re-state them.
### Consequences
- Good, because every error response has the same shape; the SPA writes one branch to parse it.
- Good, because 5xx exception details never leak — the secure-by-default for unhandled errors.
- Good, because the CSRF cookie isn't trusted: an attacker who plants a cookie via subdomain takeover would still need the session-stored token, which is also the authenticated identity. Stronger than the canonical double-submit recipe.
- Good, because rate-limit buckets are per-account when authenticated; rotating sessions doesn't dodge the limit.
- Good, because boot-time validators on `CORS_ALLOWED_ORIGINS` catch the misconfiguration in dev / staging instead of in production.
- Bad, because in-memory rate-limit store means a multi-instance BFF would have per-instance counters. Mitigated by single-instance v1; the Redis-backed migration is one constructor arg.
- Bad, because the CSRF cookie must be readable by JS (`HttpOnly: false`), so an XSS that reads `document.cookie` can lift it. Mitigated by: (a) the session cookie remains `HttpOnly`, so an XSS still needs to call the BFF from the victim's browser, which means the user has to be present; (b) CSP and helmet defaults harden the SPA delivery; (c) the SPA bundle is small + audited; (d) tighten further by introducing a CSP nonce when admin SPA renders.
- Bad, because the structured envelope doesn't include a stable per-error machine-readable detail object yet (e.g. for ValidationPipe rejections, the `message` is a semicolon-joined string). A `details` field can be added without breaking the contract.
- Neutral, because no rate limiting on anonymous mutating routes today — none exist yet. The check will move to "per-IP for unauthenticated requests" if and when those routes appear.
### Confirmation
- `apps/portal-bff/src/main.ts` mounts the five middleware in the declared order; the comment block in `bootstrap()` lists their roles and rationale.
- `apps/portal-bff/src/security/structured-error.filter.ts` carries the `ErrorResponseBody` interface + `errorResponse()` helper. Used by `csrf.middleware.ts` (line: `res.status(403).json(errorResponse('csrf', '…'))`) and by `rate-limit.middleware.ts` (handler line).
- `apps/portal-bff/src/security/csrf.middleware.ts` reads from `req.session.csrfToken` (set in `apps/portal-bff/src/auth/auth.controller.ts` at the end of `/auth/callback`); the spec pins constant-time comparison + the exempt-paths list.
- `apps/portal-bff/src/security/rate-limit.middleware.ts` exports `readRateLimitConfig()` (env parsing + validation) and `createRateLimitMiddleware()` (factory); specs cover the bucket key strategy, the strict-vs-general tiers, the `/api/health` skip, and the 429 envelope shape.
- `apps/portal-bff/src/config/check-cors-allowlist.ts` enforces the boot-time validation; spec covers every rejected case (empty, non-URL, non-http(s), path/query present).
- The SPA `csrfInterceptor` in `libs/feature/auth/src/lib/csrf.interceptor.ts` reads the cookie and copies it into the header on every mutating BFF request; the spec asserts the interceptor never touches non-BFF origins or safe methods.
- Integration tests sit in the next phase: today the contract is held by unit-level specs (199 / 199 BFF, 28 / 28 feature-auth) under a clean-env repro (`env -u` every config var). A future "BFF smoke" test will black-box-verify all five middleware on a running stack.
## Pros and Cons of the Options
### Response envelope
#### `{ error: { code, message, traceId } }` (chosen)
- Good, because single shape across status codes and across Nest-vs-middleware origins.
- Good, because `traceId` makes any reported error joinable to the full request trace.
- Good, because the `code` field lets the SPA write a `switch` instead of pattern-matching on free-form messages.
- Bad, because no machine-readable `details` for field-level validation errors yet (mitigated by adding it later without changing the envelope).
#### Nest default `{ statusCode, message, error }`
- Good, because zero work.
- Bad, because `message` may be an array (ValidationPipe), a string, or an object depending on the exception path. Inconsistent for the SPA.
- Bad, because 500s leak the exception class name in `error`.
#### Per-route ad-hoc shapes
- Bad, because every route invents its own contract; the SPA has to special-case each path.
### CSRF strategy
#### Session-bound double-submit (chosen)
- Good, because resistant to subdomain cookie injection: a planted cookie doesn't match the session-stored token.
- Good, because no shared-state needed beyond the session payload we already store in Redis.
- Bad, because requires server-side storage (we have it anyway).
#### Pure cookie-vs-header double-submit
- Good, because completely stateless on the server.
- Bad, because vulnerable to subdomain cookie injection: an attacker who can write `__Host-portal_csrf` cookie value (e.g. via subdomain takeover) can also generate the matching header.
#### `csurf` package
- Bad, because deprecated / unmaintained since 2022. Not an option.
#### Synchronizer token rendered in HTML
- Bad, because the BFF doesn't render HTML — the SPA is a separate static bundle.
### CORS allowlist
#### Mandatory env-driven, no fallback (chosen)
- Good, because forces explicit configuration in every environment.
- Good, because boot-time validator catches the misconfiguration before request handling.
- Bad, because slightly less ergonomic in fresh-clone setup (developers need to read `.env.example`; mitigated by the validator's error message pointing straight at the file to edit).
#### Hardcoded localhost fallback
- Good, because zero-config dev.
- Bad, because "works in dev, breaks in prod" trap — production silently allows nothing if the env is forgotten.
#### `*` policy
- Bad, because `credentials: true` + `*` is incompatible per the CORS spec, and `credentials: false` defeats the session cookie.
### Rate limiting bucket key
#### `sessionID` if auth, else IP (chosen)
- Good, because per-account fairness for authenticated users (one user can't drown other users from the same NAT'd IP).
- Good, because IP fallback protects against unauthenticated brute force on `/auth/login`.
#### IP only
- Bad, because a single NAT-shared IP (corporate proxy, mobile carrier) shares its quota across users — one noisy user blocks everyone.
#### Entra `oid`
- Good, because tied to the user identity rather than the session, so logout + login doesn't reset the quota.
- Bad, because `oid` is only available post-resolution. `sessionID` is available immediately and resolves to the same user via the session payload. Equivalent in practice; sessionID is what `express-rate-limit` already has on `req`.
### Rate limiting store
#### In-memory (chosen for v1)
- Good, because zero infrastructure.
- Bad, because per-instance counters when we scale out (mitigated by single-instance v1).
#### Redis-backed
- Good, because shared counters across BFF instances.
- Bad, because new infra dep + each request adds a Redis hop.
### Helmet CSP
#### Defaults in prod, disabled in dev (chosen)
- Good, because production responses carry a sensible CSP without bespoke tuning.
- Good, because dev devtools stay clean (Helmet's default `connect-src 'self'` triggers cosmetic violations on JSON responses).
- Bad, because dev / prod differ — a CSP misconfiguration won't be caught locally.
#### Custom CSP
- Good, because matches the actual BFF response shape (JSON, no scripts, no images).
- Bad, because more surface to maintain; revisited when admin SPA is served by Caddy with a real CSP nonce.
## More Information
- OWASP CSRF Cheat Sheet (double-submit + synchronizer token comparison): https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Rate Limiting Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html
- `express-rate-limit` docs: https://express-rate-limit.mintlify.app/
- `helmet` defaults reference: https://helmetjs.github.io/
- Related ADRs: [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (session cookie + CSRF design), [ADR-0010](0010-session-management-redis.md) (session storage), [ADR-0012](0012-observability-pino-opentelemetry.md) (trace id propagation, used here as the error envelope's correlation key), [ADR-0015](0015-cicd-gitea-actions.md) (DoS mitigation reference); future: the **paused** strategic security baseline ADR (ASVS level + regulatory framing), the infrastructure ADR (Redis-backed rate-limit store, CSP for admin SPA).
+23 -22
View File
@@ -42,25 +42,26 @@ The vocabulary below is the source of truth. It is intentionally coarse — prop
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column. ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
| # | Title | Status | Tags | Date | | # | Title | Status | Tags | Date |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- |
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 | | [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 | | [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 | | [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 | | [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 | | [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 | | [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 | | [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 | | [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 | | [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 | | [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 | | [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 | | [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 | | [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 | | [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 | | [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 | | [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 | | [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 | | [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 |
| [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 | | [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 |
| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 | | [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 |
| [0021](0021-phase-2-security-baseline.md) | Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope | accepted | `security`, `backend` | 2026-05-13 |