Commit Graph

15 Commits

Author SHA1 Message Date
julien db7e479dde feat(spa): opt-in 'https' nx serve config for dev-server TLS (#263)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m30s
CI / a11y (push) Successful in 1m42s
CI / check (push) Successful in 4m30s
CI / perf (push) Successful in 5m5s
## Summary

Add an opt-in `https` configuration to the SPA dev-servers so the ADR-0030 dockerised dev mode can be reached over a hostname registered in Entra. Entra refuses `http:` redirect URIs for anything other than `localhost`, which made the hostname-based access pattern (`apf-portal.dev-jg.local`, `apf-portal.dev.local`, …) — the only stable way to share a VM-based dev stack with another developer — impossible to wire to OIDC. This PR closes that gap without touching the WSL-native + localhost flow.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | New `https` Nx serve configuration: inherits the `development` build, sets `ssl: true` + `sslKey: .secrets/dev-tls.key` + `sslCert: .secrets/dev-tls.pem`. `defaultConfiguration` stays `development`; the `https` config is purely opt-in. |
| `infra/local/dev.compose.yml` | The `portal-shell` and `portal-admin` commands now end with `--configuration=${NX_SERVE_CONFIGURATION:-development}`. Compose interpolates the value at YAML parse time from `infra/local/.env`. Default is `development` (no SSL), so behaviour is unchanged for anyone who doesn't opt in. |
| `infra/local/.env.example` | New commented-out `NX_SERVE_CONFIGURATION=https` block with the rationale + pointer to the mkcert setup. |
| `apps/portal-bff/.env.example` | Comment block above the `ENTRA_*_REDIRECT_URI` defaults shows the HTTPS hostname-based override pattern (the four URIs that go with the `apps` profile when accessing via a hostname) and reminds that each override must be registered Entra-side. |
| `infra/README.md` | New "HTTPS dev-server setup — remote-browser access via a hostname" subsection: mkcert install / `mkcert -install`, cert generation, `.secrets/dev-tls.{key,pem}` convention, Entra registration reminder, `NX_SERVE_CONFIGURATION=https` opt-in. Notes that WSL-native is unaffected and that the cert path stays the same when the corp CA eventually replaces mkcert. |

## Design notes

- **Convention `.secrets/dev-tls.{key,pem}` at repo root.** Matches the existing `apps/portal-bff/.secrets/jwks.pem` pattern (gitignored via `*.pem` + `*.key`). Workspace-relative path means project.json can hardcode it and each dev drops their per-host cert there.
- **Hardcoded path, per-dev cert content.** Each developer generates a cert for **their own** hostname; the cert sits at the same fixed path on every machine. Nothing dev-specific in `project.json`.
- **`https` is opt-in, not default.** Native `nx serve` keeps booting on HTTP (`localhost:4200`) without SSL key files, exactly as before. Compose default is also `development` — only setting `NX_SERVE_CONFIGURATION=https` in `infra/local/.env` switches it on, gated by the dev having actually run the mkcert step.
- **BFF stays plain HTTP.** Only the SPA dev-server terminates TLS — the proxy then hits `http://portal-bff:3000` on the internal Compose network. Entra still gets HTTPS at the browser-facing origin, which is what its policy enforces.

## What this PR deliberately does NOT do

- It does **not** force-enable HTTPS. Devs who don't care about hostname access continue working as before.
- It does **not** touch the BFF code, the Entra config helpers, or the auth flow itself. The whole change is config (project.json + compose + env-examples) + docs.
- It does **not** ship the shared VM cert story. That needs a corp-CA-signed cert (or a shared mkcert CA distributed across workstations); flagged in the README section as a follow-up.

## Test plan

- [x] Both `project.json` files parse as JSON; `nx show project` exposes the new `https` configuration with the expected SSL options.
- [x] `docker compose -f dev.compose.yml --profile apps config` validates with `NX_SERVE_CONFIGURATION=https` (resolves to `--configuration=https`) and without it (resolves to `--configuration=development`).
- [ ] **On vm-jg**: `mkcert -install` on the workstation, `mkcert` against `apf-portal.dev-jg.local`, copy `.secrets/dev-tls.{key,pem}` to the VM, set `NX_SERVE_CONFIGURATION=https` in `infra/local/.env`, register the four `https://apf-portal.dev-jg.local:*` URIs in Entra, restart `dev.sh up apps`, then open `https://apf-portal.dev-jg.local:4200/` from the workstation → SPA loads, no cert warning, sign-in completes and returns to the SPA via the OIDC callback.
- [ ] Native WSL flow unchanged: `nx serve portal-shell` still boots on `http://localhost:4200/`, OIDC against the existing `http://localhost:3000/...` Entra URIs still works.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this completes for the hostname-access case.
- [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — per-environment SPA config strategy; `https` is a new Nx serve _configuration_, not a new `environment.ts` sibling, so 0018's build-time replacement story is unchanged.
- [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — OIDC flow; no behaviour change, only the redirect-URI strings.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #263
2026-06-01 16:15:36 +02:00
julien 045ff924a8 fix(infra): exclude serve-static from 'dev.sh up all' (port collision with apps) (#261)
CI / check (push) Successful in 4m9s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m33s
CI / a11y (push) Successful in 1m49s
CI / perf (push) Successful in 5m30s
## Summary

Fix `./infra/local/dev.sh up all` failing on `port is already allocated` for port 4200: the `apps` profile (ADR-0030 — Angular dev-server on 4200) and the `serve-static` profile (Caddy reverse proxy for the production build, also defaulting to 4200) cannot run together. `up all` expanded to "every profile" without taking that conflict into account.

After this PR, `./infra/local/dev.sh up all` brings up the comprehensive dev stack — infra + `dbtools` + `observability` + `apps` — and `serve-static` stays available via the explicit `./infra/local/dev.sh up serve-static` invocation when a developer actually wants to test a production build.

## Root cause

`dev.compose.yml` publishes port 4200 in two services:

- `portal-shell` (profile `apps`): `${SHELL_PORT:-4200}:4200` — Angular dev-server.
- `serve-static` (profile `serve-static`): `${SERVE_STATIC_PORT:-4200}:4200` — Caddy serving the production build.

`dev.sh`'s `ALL_PROFILES` array was used both as:
1. the teardown / status / logs scope (so a manually-started profile is still reachable for `down` and friends), **and**
2. the expansion target for `up all`.

Conflating the two meant `up all` always tried to start `serve-static` even when `apps` was in scope — and the Docker port-publishing collision aborted the whole `up`.

## Fix

Split the two concerns:

```bash
# Every profile that exists — teardown / status / logs scope.
ALL_PROFILES=(dbtools observability serve-static apps)

# What `up all` expands to — serve-static excluded.
UP_ALL_PROFILES=(dbtools observability apps)
```

`serve-static` stays accessible:

- via explicit `dev.sh up serve-static` (the original way),
- via `dev.sh down` / `status` / `logs` (still in `ALL_PROFILES`).

Why exclude `serve-static` from `up all` rather than `apps`:

- `apps` is the new "no native toolchain" dev mode (ADR-0030) — it _is_ what a comprehensive dev `up` should boot.
- `serve-static` has zero value without a prior `nx build --configuration=production` (Caddy serves an empty `dist/` → 404 everywhere). Auto-starting it in `up all` would put a 404-machine in the stack by default.
- The port number can stay at the Angular convention (4200) for the dev-server, which matches the devcontainer's `forwardPorts`.

## What lands

| File | Change |
| --- | --- |
| `infra/local/dev.sh` | Split `ALL_PROFILES` (teardown scope) and `UP_ALL_PROFILES` (`up all` scope). Inline comment explains the exclusion of `serve-static`. Usage text updated. |
| `infra/README.md` | Cheat-sheet row for `up all` clarifies the new behaviour. |

## Out of scope

The script is **environment-agnostic** — it works identically when invoked on the local workstation or on `vm-dev`. Port publishing is on `0.0.0.0` by default, so services are reachable from a remote browser via the VM IP without any script change. The user-visible difference is only the URL host (`localhost:4200` locally vs `<vm-ip>:4200` from the workstation against the VM). No local-vs-vm mode in the script.

## Test plan

- [x] `bash -n infra/local/dev.sh` passes.
- [x] `./infra/local/dev.sh help` shows the updated `up all` description.
- [ ] **On vm-dev**: `./infra/local/dev.sh up all` brings up postgres / redis / otel-collector / pgweb / jaeger / apps-deps (exits 0) / portal-bff / portal-shell / portal-admin without port conflicts; `serve-static` is **not** started.
- [ ] `./infra/local/dev.sh up serve-static` (explicit) still starts Caddy on 4200, provided `apps` is **not** already up.
- [ ] `./infra/local/dev.sh down` and `status` still see serve-static if it was started manually (ALL_PROFILES retains it).

## Related

- Surfaced by the ADR-0030 dockerised dev mode VM validation — `up all` was added to the user-facing surface in that PR; this PR finishes wiring it.
- The port collision was flagged as a caveat in the ADR-0030 PR body ("don't run `apps` and `serve-static` together"). This PR turns the caveat into a script invariant instead of relying on the user to remember.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #261
2026-06-01 13:07:08 +02:00
julien a84ea2d116 feat(spa): proxy /api in dev-server, relative bffApiBaseUrl (#259)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m39s
CI / check (push) Successful in 7m6s
CI / a11y (push) Successful in 2m38s
CI / perf (push) Successful in 7m52s
## Summary

Make the SPAs reach the BFF as a **same-origin** call via an Angular dev-server `/api` proxy. Solves the "Backend unreachable" error surfaced during the ADR-0030 dockerised dev mode VM validation when the SPA is accessed from a remote browser (e.g. `http://<vm-ip>:4200/`), and bypasses CORS in dev altogether. Follow-up to the just-merged ADR-0030 implementation.

## Root cause it fixes

Before this PR, both SPAs hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (per ADR-0018) and the BFF's `CORS_ALLOWED_ORIGINS` only allowed `http://localhost:4200,http://localhost:4300`. Both assumptions hold for native `nx serve` (developer on the same machine as the BFF) but break the moment the browser sits on a different host than the BFF — exactly the case for the ADR-0030 `apps` Compose profile: open `http://<vm-ip>:4200/` from your workstation and the SPA's `localhost:3000` call hits your **workstation's** loopback (nothing there), not the VM's BFF. Even if the URL were right, the origin `http://<vm-ip>:4200` is not in the CORS allowlist.

## Fix

Switch to a same-origin dev pattern: the Angular dev-server proxies `/api/*` to the BFF, and `environment.ts` uses a relative `'/api'` URL.

| File | Change |
| --- | --- |
| `apps/portal-shell/proxy.conf.js` | **New.** Maps `/api → ${BFF_TARGET:-http://localhost:3000}` (JS form so the env var can swap the target at startup). |
| `apps/portal-admin/proxy.conf.js` | **New.** Same shape. |
| `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | `serve.options.proxyConfig` points at the new file. |
| `apps/portal-shell/src/environments/environment.ts`, `apps/portal-admin/src/environments/environment.ts` | `bffApiBaseUrl: 'http://localhost:3000/api'` → `'/api'`. The comment block explains the rationale + how production siblings can still use an absolute origin if SPA + BFF live on different hosts. |
| `apps/portal-shell/src/observability/tracing.ts` | `new URL(environment.bffApiBaseUrl)` would throw on a relative URL — resolved against `window.location.origin` so both relative (dev) and absolute (prod cross-origin) bases work. |
| `infra/local/dev.compose.yml` | `BFF_TARGET=http://portal-bff:3000` added to `portal-shell` and `portal-admin` so the proxy hits the BFF **container** by name (Compose DNS) when the `apps` profile is up. Native `nx serve` leaves the var unset and falls back to `localhost:3000`. |

## Why a relative URL is safe

- `${bffApiBaseUrl}/health` etc. compose to `/api/health` — relative paths work in `fetch` / `HttpClient`.
- `tracing.ts` propagates `traceparent` on requests whose origin matches the BFF origin. Resolving the relative base against `window.location.origin` gives the current page's origin, which is exactly the origin the dev-server proxy serves from — so the regex still matches the right requests in dev. In a future cross-origin production deployment, `environment.prod.ts` can set an absolute `bffApiBaseUrl`; the URL constructor's second arg is ignored when the first is absolute, so the same code path keeps working.
- Auth flow (`feature-auth` / `auth.config.ts`): consumes `bffApiBaseUrl` via DI as a string prefix — agnostic to absolute vs relative.

## Scope notes

- The OTel HTTP exporter (`environment.otlpEndpoint = 'http://localhost:4318/v1/traces'`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) **remain absolute**. They hit the same remote-browser problem on `apps`-profile access, but neither is blocking the user-visible "Backend unreachable" path this PR targets. Out of scope here; a follow-up could either proxy them too or surface them via runtime config.
- This pattern is dev-server only — production builds do not use the proxy. Per-environment `bffApiBaseUrl` overrides remain the supported lever (ADR-0018), unchanged.

## Test plan

- [x] `docker compose -f dev.compose.yml --profile apps config` still validates.
- [ ] **On the VM**, `./infra/local/dev.sh up apps`, then in the workstation browser open `http://<vm-ip>:4200/` → SPA loads, the "Backend unreachable" message is gone, network tab shows `/api/...` calls succeeding (same-origin, no CORS preflight).
- [ ] Native `nx serve portal-shell` still works (the proxy falls back to `localhost:3000`).
- [ ] Trace headers (`traceparent`) appear on `/api/*` fetches in the browser network tab.
- [ ] `pnpm exec nx affected -t test build` green on the two SPAs.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this enables to actually work from a remote browser.
- [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — the `environment.ts` per-env strategy this complements (does not supersede — production behaviour unchanged).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #259
2026-06-01 11:39:40 +02:00
julien c080d1ad89 feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030) (#258)
CI / scan (push) Successful in 3m8s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m58s
CI / a11y (push) Successful in 4m38s
Docs site / build (push) Successful in 7m47s
CI / perf (push) Successful in 8m53s
## Summary

Implements [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md) (now `accepted`): a Docker Compose `apps` profile that runs the three Nx dev servers (`portal-bff`, `portal-shell`, `portal-admin`) from a shared `Dockerfile.dev`, so a developer can boot the whole stack with **no native Node/pnpm**:

```bash
./infra/local/dev.sh up apps   # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```

Purely additive and profile-gated — the native `nx serve` flow and the devcontainer are untouched. Dev-only; no production images (those stay with the ADR-0028 Container Registry work).

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0030-dockerised-dev-mode.md` | Status `proposed` → `accepted`. |
| `docs/decisions/README.md` | Index status → `accepted`. |
| `infra/local/Dockerfile.dev` | **New.** `node:24-bookworm` + corepack (pnpm resolved from `packageManager` at runtime — no pinned version to drift). No COPY/install at build time. `NX_DAEMON=false`, `NODE_OPTIONS=--max-old-space-size=4096`. |
| `infra/local/dev-entrypoint.sh` | **New.** Shared entrypoint: BFF (`APF_ROLE=bff`) runs `prisma generate` + `prisma migrate deploy` then serves; SPA services go straight to `nx serve`. |
| `infra/local/dev.compose.yml` | **New `apps` profile.** A one-shot `apps-deps` service installs into a shared `node_modules` volume once (the 3 servers gate on its `service_completed_successfully`, avoiding a 3-way install race); `portal-bff` / `portal-shell` / `portal-admin` services from the shared image via a `x-app-base` anchor. Repo bind-mounted; `node_modules` + `.nx` in named volumes. |
| `infra/local/dev.sh` | `apps` added to `ALL_PROFILES` (so teardown / status / logs catch it) + usage / examples. |
| `infra/README.md` | New "Dockerised app dev mode" section + cheat-sheet / file-table rows. |
| `docs/setup/01-dev-debian-vm-setup.md` | "Three dev modes — which when" table at the top of Step 5. |
| `CLAUDE.md` | Architecture roll-up bullet + ADR-count line + environment-conventions note. |

## Key design decisions

- **One image, one install.** The monorepo means a single `Dockerfile.dev` + a single `pnpm install` serves all three apps.
- **`node_modules` + `.nx` in named volumes, not bind-mounted.** The container's install (native modules — `esbuild`, `@swc/core`, Prisma engines, `lmdb`, `@parcel/watcher` — built for this image) must never be shadowed by the host's `node_modules`. The repo source is bind-mounted for hot reload; these two directories are overlaid with named volumes.
- **`apps-deps` one-shot avoids the install race.** Three services sharing one `node_modules` volume can't all run `pnpm install` concurrently. A dedicated install service runs first; the three app services `depends_on` its completion.
- **`NX_DAEMON=false`** in the containers — three containers sharing one workspace would otherwise contend on the Nx daemon.
- **Env wiring.** The BFF reuses its own `apps/portal-bff/.env` (Entra / session / jwks secrets) via `env_file: { required: false }`; the host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden in `environment:` — rebuilt from `infra/local/.env` creds → Compose service names. Compose `environment` wins over `env_file`, so the localhost values in the BFF `.env` don't leak into the container.
- **BFF still needs its secrets.** "No native toolchain" ≠ "no config". `apps/portal-bff/.env` must exist (same as native dev); `required: false` lets SPA-only devs `up` without it (the BFF then fails its own boot validators with a clear message).

## Validation on the VM

- [x] `docker compose -f dev.compose.yml --profile apps config` validates (YAML, anchors / merge, env interpolation).
- [x] `bash -n` clean on `dev-entrypoint.sh` and `dev.sh`.
- [x] **Full boot on vm-dev** — `./infra/local/dev.sh up apps` brings up postgres / redis / otel + `apps-deps` (one-shot, exit 0) + portal-bff / portal-shell / portal-admin, all containers report healthy or running.
- [x] `apps-deps` populates the shared `node_modules` volume; the three servers reach their `nx serve` step without re-installing.
- [x] Ports published as expected: BFF :3000, portal-shell :4200, portal-admin :4300.
- [x] `./infra/local/dev.sh up` (no `apps`) unchanged for native devs.

## Follow-ups identified during VM validation

- **SPA → BFF reachability from a remote browser.** Opening `http://<vm-ip>:4200/` from the workstation surfaces a "Backend unreachable" message: the SPA's hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (ADR-0018 build-time env) plus the BFF's `CORS_ALLOWED_ORIGINS=http://localhost:4200,…` both assume "browser on the same machine as the BFF", which doesn't hold here. Fixed in the **stacked follow-up PR `feat/spa-dev-proxy`** (proxy `/api` in the Angular dev-server + relative `bffApiBaseUrl`), which lands right after this PR.
- The OTel HTTP exporter URL (`environment.otlpEndpoint`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) remain absolute and hit the same remote-browser limit; not blocking for v1, can be revisited if needed.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the decision (accepted in this PR's chain).
- [ADR-0020](docs/decisions/0020-portal-admin-app.md) — the devcontainer this complements.
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — production images / Container Registry (deferred).
- Follow-up branch `feat/spa-dev-proxy` — the SPA-side proxy fix that makes the dockerised mode usable from a remote browser.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #258
2026-06-01 11:11:44 +02:00
julien fe180fd125 feat(infra): local serve-static profile — Caddy reverse proxy for the prod build (#97)
CI / check (push) Successful in 2m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m35s
CI / perf (push) Successful in 4m16s
## Summary

Add a Caddy reverse proxy behind a new `--profile serve-static` so a contributor can exercise the production build locally with the per-locale routing the on-prem reverse proxy will use (per ADR-0019). Closes the gap surfaced by PR #96: the locale switcher / accessibility route fusion / cookie plumbing all need a prod-faithful local setup, and `nx serve-static` falls short (no SPA fallback per locale, no smart `/` redirect, exposes the `http-server` directory-listing footgun we hit during the perf-gate fix in PR #92).

## What lands

- **[`infra/local/Caddyfile`](infra/local/Caddyfile)** — explicit `route` block:
  - `GET /` → 302 to `/{locale}/` based on `Accept-Language`, falling back to `/fr/` (APF audience).
  - `/fr/*` → `dist/.../browser/fr/` with SPA fallback to `fr/index.html`.
  - `/en/*` → mirror.
  - Catch-all → 302 to `/fr/`.
- **[`infra/local/dev.compose.yml`](infra/local/dev.compose.yml)** — new `serve-static` service on the `serve-static` profile. Bind-mounts the Caddyfile and `dist/apps/portal-shell/browser/` read-only. Port 4200, overridable via `SERVE_STATIC_PORT`.
- **[`infra/local/.env.example`](infra/local/.env.example)** — adds `SERVE_STATIC_PORT=4200`.
- **[`infra/local/dev.sh`](infra/local/dev.sh)** — registers `serve-static` in `ALL_PROFILES` so `dev.sh down|status|logs` catches the new container, and `dev.sh up serve-static` works.
- **[`infra/README.md`](infra/README.md)** — file row, workflow snippet, cheat-sheet row, and a service-endpoint row with the `nx build … -c=production` prerequisite called out.

## Workflow

```
pnpm exec nx build portal-shell --configuration=production
./infra/local/dev.sh up serve-static
open http://localhost:4200/   # → /fr/ or /en/ per Accept-Language
```

## Decision worth flagging

Used **Caddy** rather than nginx or Traefik. Reason: minimal Caddyfile, single binary, no daemon config drift, sensible defaults (TLS off explicitly for local-only). Same family of choice as the rest of `infra/local/` — small, single-purpose images.

`redir` in a Caddyfile is **ambiguous** when the first arg starts with `/`: Caddy reads it as a path matcher rather than a redirect target. Using `redir * /fr/ 302` (explicit `*` matcher) avoids the gotcha. Documented inline in the Caddyfile via a comment block.

## What this PR explicitly does NOT do

- Wire TLS. Local convenience only, binds to `localhost`.
- Replace `nx run portal-shell:serve-static` (still used by Lighthouse CI in `ci:perf`).
- Set the `__Host-portal_locale` cookie or honour it for the smart redirect. Cookie handling needs the BFF route (ADR-0019 future PR).
- Land an on-prem reverse-proxy ADR. The on-prem infra ADR is phase 3b.

## Verified locally

| Probe | Expected | Actual |
|---|---|---|
| `GET / -H 'Accept-Language: fr'` | 302 `/fr/` | ✓ |
| `GET / -H 'Accept-Language: en'` | 302 `/en/` | ✓ |
| `GET /unknown` | 302 `/fr/` | ✓ |
| `GET /fr/deep/route` | 200 (SPA fallback to `fr/index.html`) | ✓ |
| `GET /fr/favicons/favicon.svg` | 200 (asset under locale folder) | ✓ |
| `/fr/index.html` markup | `lang="fr"`, `<base href="/fr/">` | ✓ |
| `/en/index.html` markup | `lang="en"`, `<base href="/en/">` | ✓ |

## Test plan

- [x] `docker compose -f infra/local/dev.compose.yml --profile serve-static config` validates clean.
- [x] `dev.sh up serve-static` brings the container up; `dev.sh status` lists it; `dev.sh stop serve-static` brings it down.
- [x] Routing probes above all pass.
- [ ] Manual: build + serve-static + click the locale switcher → URL becomes `/{other-locale}/`, the matching bundle boots, no console errors. (Verifies PR #95 + #96 end-to-end against a prod-faithful proxy.)
- [ ] Manual: `/fr/accessibilite` → router-level redirect to `/fr/accessibility` (verifies PR #94 under SPA fallback).
- [ ] Manual: `Accept-Language: en` in browser settings → root URL lands on `/en/`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #97
2026-05-12 00:06:33 +02:00
julien 8f2cd4e068 feat(portal-shell): wire spa-side opentelemetry tracing (#72)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m15s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 2m0s
CI / check (push) Successful in 43s
## Summary

Phase 2 of ADR-0012 — closes the loop SPA → BFF → DB. After this PR, a single user action (initial page load, click, form submit) produces one trace whose root span is owned by the SPA and whose child spans cover the BFF request, Postgres queries through Prisma, and (eventually) Redis / downstream-API hops.

## What lands

**Browser-side OTel libs** (production deps):

- `@opentelemetry/sdk-trace-web` — browser tracer + provider
- `@opentelemetry/exporter-trace-otlp-http` — OTLP/HTTP+JSON exporter
- `@opentelemetry/instrumentation` — auto-instrumentation runtime
- `@opentelemetry/instrumentation-fetch` — `fetch` + W3C `traceparent` propagation
- `@opentelemetry/instrumentation-document-load` — initial-paint timings
- `@opentelemetry/instrumentation-user-interaction` — click / keypress / submit

No `@opentelemetry/context-zone`: the workspace is zoneless per ADR-0004; the default `StackContextManager` covers the auto-instrumented paths. Custom spans across `await` will need explicit `context.with(...)` plumbing — fine, encountered as code lands.

**Code**:

- [`apps/portal-shell/src/observability/tracing.ts`](apps/portal-shell/src/observability/tracing.ts) — `WebTracerProvider` bootstrap. Documents the load-order constraint inline (same pattern as the BFF: must be the very first import of `main.ts`, otherwise auto-instrumentations miss everything imported above).
- `apps/portal-shell/src/main.ts` now imports the tracing module as line 1.

**CORS plumbing** for end-to-end trace propagation:

- BFF (`apps/portal-bff/src/main.ts`) calls `enableCors` with a minimal dev allowlist (`http://localhost:4200`) and explicit permission for the W3C `traceparent` / `tracestate` headers. The full security-grade CORS (per-environment allowlists, helmet, cookie-session, CSRF) belongs to the future phase-2 security ADR — this PR adds the strict minimum for the SPA→BFF trace context to survive cross-origin pre-flight.
- OTel Collector (`infra/local/otel-collector.yaml`) gains a `cors` block on its OTLP/HTTP receiver so the browser's own OTLP POST clears its pre-flight.

**ADR-0012 §Confirmation** rewritten: a new "Wired in the SPA foundation PR (phase 2)" block enumerates what landed here; the carry-over "Wired as features land" list drops the SPA-side SDK item and adds a follow-up note about the security-grade CORS.

## Verification

```bash
pnpm exec nx run-many -t lint test build           # 8 projects green
pnpm audit                                          # 0 vulns
./infra/local/dev.sh up observability               # bring up Collector + Jaeger
./infra/local/dev.sh                                # (separately, BFF stack — your choice)
pnpm nx serve portal-bff                           # localhost:3000
pnpm nx serve portal-shell                         # localhost:4200
```

Open http://localhost:4200 → a `document_load` trace appears in http://localhost:16686 with `service.name=portal-shell`. From DevTools, run `fetch('http://localhost:3000/api/health').then(r => r.json())` → a fetch span appears with a child BFF span on the same trace.

## Test plan

- [ ] CI green on this PR.
- [ ] After local up, `document_load` span visible in Jaeger UI for the SPA.
- [ ] Cross-origin fetch from SPA carries `traceparent` (visible in Network tab) and produces a single end-to-end trace SPA → BFF in Jaeger.
- [ ] DevTools console shows no CORS warnings about `traceparent`, `tracestate`, or the `localhost:4318/v1/traces` POST.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #72
2026-05-09 23:23:18 +02:00
julien b74d3f1b9b feat(portal-bff): observability foundations (Pino + CLS + OTel) (#70)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m33s
CI / check (push) Successful in 2m3s
CI / a11y (push) Successful in 1m11s
CI / perf (push) Successful in 2m14s
## Summary

Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR.

The BFF now emits structured JSON logs to stdout, tagged with `trace_id` / `span_id` from the active OTel context, and exports OTLP traces over HTTP/Protobuf to the Collector that already runs in the local-dev compose. Anything Nest, Express, HTTP-out, Prisma (Postgres) or `ioredis` does is auto-spanned. A `GET /api/health` liveness endpoint is added to round things out.

## What lands

**Runtime libs added** (production deps):

- `nestjs-pino`, `pino`, `pino-http` — structured logging
- `nestjs-cls` — request-scoped context
- `@opentelemetry/api` / `sdk-node` / `resources` / `semantic-conventions`
- `@opentelemetry/exporter-trace-otlp-proto` (HTTP/Protobuf, port 4318)
- `@opentelemetry/instrumentation-{http,express,nestjs-core,pg,ioredis,pino}` — curated, **no** `auto-instrumentations-node` mass-import (anti-bricolage)

Dev: `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`). Pure side-effect module.
- `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, `X-Request-Id` honoured if inbound).
- `apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts` — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — `/readiness` lands when dependencies have a readiness story.
- `apps/portal-bff/src/config/check-database-url.{ts,spec.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 character in `POSTGRES_PASSWORD` that needs URL-encoding in `DATABASE_URL`. Prisma requires a URL string (no discrete-flag escape hatch), so early validation + a 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` imports `./observability/tracing` as line 1, then uses `app.get(Logger)` from `nestjs-pino` with `bufferLogs: true` so early-bootstrap lines are not lost.
- `app.module.ts` imports `ObservabilityModule` first, then `PrismaModule`, then `HealthModule`.
- `apps/portal-bff/.env.example` promotes `LOG_LEVEL`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_TRACES_SAMPLER` from the "future" comment to active settings — defaults target the local-dev Collector.
- Both `apps/portal-bff/.env.example` and `infra/local/.env.example` now spell out the URL-encoding constraint on `POSTGRES_PASSWORD` with the char-by-char encoding table (`@` → `%40`, etc.).

**ADR-0012 §Confirmation** rewritten to distinguish what landed in this PR from what is wired as the corresponding feature ADRs ship (CLS keys for `session_id` / `user_id_hash` / `audience`, `LOG_USER_ID_SALT` enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).

## Trace ↔ log correlation

Automatic via `@opentelemetry/instrumentation-pino` — every Pino record gets `trace_id` and `span_id` injected from the active OTel context. No CLS gymnastics needed for that concern.

## Verification

```bash
pnpm exec nx run-many -t lint test build       # 8 projects green
pnpm audit --audit-level=moderate              # 0 vulnerabilities
./infra/local/dev.sh up observability          # start Collector + Jaeger
cp apps/portal-bff/.env.example apps/portal-bff/.env
pnpm nx serve portal-bff
curl http://localhost:3000/api/health
# → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"}
```

Then hit `GET http://localhost:3000/api` once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching `trace_id`.

## Test plan

- [ ] `nx run-many -t lint test build` green on this PR's CI run.
- [ ] `pnpm audit` clean.
- [ ] BFF boots, `/api/health` returns the expected JSON.
- [ ] Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by `NODE_ENV=production`).
- [ ] With the local-dev stack's `--profile observability` active, traces are visible in Jaeger UI.
- [ ] Each Pino log line for a request carries the same `trace_id` as the trace span in Jaeger.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #70
2026-05-09 22:28:17 +02:00
julien fc9b63f24a feat(infra): add dev.sh wrapper for the local-dev compose stack (#68)
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 3m1s
## Summary

Two recurring frictions on the local-dev stack:

1. **Compose-profile asymmetry** — `docker compose down` only operates on services whose profile is currently active. Anything brought up with `--profile X` keeps running unless the same flag is passed on `down`. pgweb and Jaeger silently survived several `down -v` invocations before we noticed (#67 documented the gotcha; this PR makes it impossible to hit if you use the wrapper).
2. **Verbose invocations** — typing `docker compose -f infra/local/dev.compose.yml --profile … <verb>` for routine ops gets old fast.

Add [`infra/local/dev.sh`](infra/local/dev.sh) as a thin wrapper. Always passes every profile in scope on teardown / status / log commands, exposes ergonomic verbs:

| Command                                       | Effect                                                          |
| --------------------------------------------- | --------------------------------------------------------------- |
| `./infra/local/dev.sh up`                     | Core only (postgres + redis + otel-collector)                   |
| `./infra/local/dev.sh up all`                 | Core + every profile                                            |
| `./infra/local/dev.sh up dbtools`             | Core + pgweb                                                    |
| `./infra/local/dev.sh up observability`       | Core + Jaeger                                                   |
| `./infra/local/dev.sh down [-v]`              | Tear down (every profile in scope, no orphaned services)        |
| `./infra/local/dev.sh stop <service>`         | Stop one service (containers stay around)                       |
| `./infra/local/dev.sh restart <service>`      | Restart one service                                             |
| `./infra/local/dev.sh status`                 | `ps` with every profile visible                                 |
| `./infra/local/dev.sh logs [service]`         | Follow logs                                                     |
| `./infra/local/dev.sh exec <service> <cmd>`   | Run a command inside a container                                |

Anything not matching one of the named verbs is passed through to `docker compose -f dev.compose.yml ...` (with every profile flagged in), so the full Compose surface remains available.

## Doc updates

- **`infra/README.md`** — new "Convenience script" subsection with the cheat-sheet table; "First-time setup" rewritten to use the script; the standalone "Profile symmetry" tip from #67 is collapsed into a one-liner since the script now handles it (the note remains as a fallback for direct `docker compose` users).
- **`docs/development.md` §3** — points at the script for the typical setup flow.

The compose file itself is unchanged.

## Test plan

- [ ] `./infra/local/dev.sh help` prints the usage block.
- [ ] `./infra/local/dev.sh up` brings up the 3 core services (no pgweb / no jaeger).
- [ ] `./infra/local/dev.sh up all` adds pgweb and Jaeger.
- [ ] `./infra/local/dev.sh down -v` stops and removes all 5 containers (incl. pgweb and Jaeger), wipes the postgres-data and redis-data volumes.
- [ ] `./infra/local/dev.sh stop pgweb` stops just pgweb.
- [ ] `./infra/local/dev.sh logs otel-collector` follows that service's logs.
- [ ] `./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\du"` lists the audit roles.
- [ ] `./infra/local/dev.sh stop` (no arg) errors with a clear message.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #68
2026-05-09 21:50:37 +02:00
julien a893f8e06b chore(infra): migrate Jaeger to v2 for built-in dark theme (#64)
CI / check (push) Successful in 1m23s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 57s
CI / a11y (push) Successful in 49s
CI / perf (push) Successful in 2m5s
## Summary

Jaeger v1's web UI has no theme selector — it ships light-only. v2 (the OTel-Collector-based rewrite, image `jaegertracing/jaeger`, distinct from v1's `jaegertracing/all-in-one`) ships a **Light / Dark / Auto** switcher in the UI nav. v2 is also the actively-developed line; v1 is on the way out within ~6-12 months.

Migration is mechanical:

- Image: `jaegertracing/all-in-one:1.76.0` → `jaegertracing/jaeger:2.17.0`.
- Drop `COLLECTOR_OTLP_ENABLED: 'true'` env — v2 enables OTLP receivers by default.
- UI port (`16686`) and OTLP ports (`4317` / `4318`) are unchanged, so the collector → `jaeger:4317` forwarding pipeline keeps working without touching `otel-collector.yaml`.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile observability up -d --force-recreate jaeger` → container stays healthy.
- [ ] http://localhost:16686 loads the v2 UI; nav shows the Light/Dark/Auto switcher.
- [ ] Send a synthetic OTLP trace to `localhost:4317` (e.g., via grpcurl or once the BFF is wired in B) → it appears in the Jaeger UI.
- [ ] OTel Collector logs no longer warn abou

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #64
2026-05-08 23:31:16 +02:00
julien 171f21b99b fix(infra): pass pgweb credentials via discrete CLI flags (#63)
CI / check (push) Successful in 1m30s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / a11y (push) Successful in 42s
CI / perf (push) Successful in 2m18s
## Summary

`PGWEB_DATABASE_URL` (post-#62 rename) still fails at boot:

```
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

Root cause: the userinfo portion of a Postgres URL must be URL-encoded — any `@`, `#`, `:`, `/`, `?`, `%`, `&`, `=`, `+`, `;` etc. in the password breaks the parser. Compose has no built-in URL encoding, so the URL we construct in YAML is fragile by design and depends on the developer happening to pick a URL-safe password.

Switch to pgweb's discrete CLI flags (`--host`, `--port`, `--user`, `--pass`, `--db`, `--ssl`). Compose interpolates each value literally — no URL encoding required, any password works.

The image's ENTRYPOINT already passes `--bind=0.0.0.0 --listen=8081`; our args are appended to those.

## Side benefit

A missing password now yields an explicit Compose error (`POSTGRES_PASSWORD must be set in infra/local/.env`) rather than an opaque pgweb crash with a vague "Invalid URL" message.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays up (no `Restarting (1)` loop).
- [ ] http://localhost:8081 loads pgweb's UI; you can navigate to the `audit` schema.
- [ ] Confirm with a password that contains a special char (e.g., `dev@pass#2026!`) — should still work.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #63
2026-05-08 23:13:17 +02:00
julien 10f8565957 fix(infra): rename pgweb DATABASE_URL to PGWEB_DATABASE_URL (#62)
CI / check (push) Successful in 1m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m23s
## Summary

pgweb 0.16 deprecated `DATABASE_URL` in favour of `PGWEB_DATABASE_URL`. With the old name, the container starts up, emits a `[DEPRECATION]` warning, then crashes:

```
[DEPRECATION] Usage of DATABASE_URL env var is deprecated, please use PGWEB_DATABASE_URL variable instead
Pgweb v0.16.2 ...
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

— because the new code path reads `PGWEB_DATABASE_URL` (empty) and the URL parser rejects an empty string.

Rename the env key in the compose file. Same value, just the new name.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays running (not in `Restarting` loop).
- [ ] http://localhost:8081 loads pgweb's UI; click through to the `audit` schema and the four `audit_*` roles to confirm DB connection.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #62
2026-05-08 22:58:21 +02:00
julien 6137486d64 fix(infra): grant audit roles to current_user, not hardcoded portal (#60)
CI / check (push) Successful in 1m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m6s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 2m13s
## Summary

The bootstrap SQL ended with:

```sql
GRANT audit_owner, audit_writer, audit_reader, audit_archiver TO portal;
```

— hard-coded `portal`. The compose file and `.env.example` both document `POSTGRES_USER` as overridable; any contributor who changed it hit:

```
ERROR: role "portal" does not exist
psql: /docker-entrypoint-initdb.d/01-init.sql:48: ERROR: role "portal" does not exist
```

Replace with `current_user`, which resolves at execution time to whoever is running the init SQL — i.e. the superuser Postgres just created from `POSTGRES_USER`, whatever its name.

## Recovery for anyone hit by the bug

The half-failed init left the postgres-data volume in a partially-initialised state. To reset:

```bash
cd infra/local
docker compose -f dev.compose.yml down -v   # wipes the volume
docker compose -f dev.compose.yml up -d     # bootstrap re-runs cleanly
```

## Test plan

- [ ] After merge + recovery: `docker compose ps` shows postgres healthy.
- [ ] `psql postgres://<user>:<pwd>@localhost:5432/portal_dev -c "\du"` lists the four `audit_*` roles, and your superuser is "Member of: {audit_owner, audit_writer, audit_reader, audit_archiver}".
- [ ] `psql ... -c "\dn"` shows the `audit` schema.
- [ ] Test with a non-default `POSTGRES_USER` value (set `POSTGRES_USER=apf_portal` in `.env`, wipe volume, re-up) — init still succeeds.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #60
2026-05-08 21:44:05 +02:00
julien d5c5c45175 fix(infra): correct OTel collector image tag (#59)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m1s
CI / scan (push) Successful in 1m9s
CI / a11y (push) Successful in 39s
CI / perf (push) Successful in 1m58s
## Summary
`otel/opentelemetry-collector-contrib:0.115.0` doesn't exist — release 0.115 was published as `0.115.1` only. Same memory-not-Docker-Hub mistake as Jaeger in #58.

```
Error response from daemon: failed to resolve reference
"docker.io/otel/opentelemetry-collector-contrib:0.115.0": not found
```

Pin to `0.150.1` (latest stable). The collector's stable-feature backward-compat covers our `otel-collector.yaml` (otlp receiver, batch processor, debug exporter, otlp/jaeger exporter) — no config change needed.

Postgres `17.2-alpine`, Redis `7.4-alpine`, pgweb `0.16.2` were verified against Docker Hub at the same time; they're all correct.

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml up -d` — all 3 core services pull and start healthy.
- [ ] `--profile observability up -d` adds Jaeger 1.76.0 (after #58).
- [ ] `--profile dbtools up -d` adds pgweb 0.16.2.
- [ ] `docker compose ps` shows everything healthy.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #59
2026-05-08 20:41:00 +02:00
julien f0372adaae fix(infra): correct Jaeger image tag (#58)
CI / check (push) Successful in 1m19s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m21s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m13s
## Summary
`jaegertracing/all-in-one:1.62` doesn't exist on Docker Hub — I picked it from memory in #57 without verification. Jaeger 1.x publishes only full-semver tags (1.X.Y), not rolling minor (`1.X`) tags.

Activating `--profile observability` errored at image-pull time:

```
Error response from daemon: failed to resolve reference
"docker.io/jaegertracing/all-in-one:1.62": not found
```

Pin to `1.76.0` (latest stable in the 1.x line, verified against Docker Hub).

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml --profile observability up -d` → all images pull, all services healthy.
- [ ] http://localhost:16686 → Jaeger UI loads (empty until the BFF starts emitting traces, which lands in the upcoming **B — Observability foundations** PR).
- [ ] Renovate's docker-compose manager picks up future Jaeger 1.x bumps and surfaces them as PRs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #58
2026-05-08 20:08:33 +02:00
julien 0f00d6d93f feat(infra): add local-dev Docker Compose stack (#57)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m12s
CI / scan (push) Successful in 1m20s
CI / a11y (push) Successful in 41s
CI / perf (push) Successful in 2m9s
## Summary
Bring up Postgres + Redis + OTel Collector in one command so contributors can run the BFF end-to-end without manually wiring each service. Replaces the throwaway `docker run postgres:17-alpine` one-liner that was in `docs/development.md` §3.

### What lands
- **`infra/local/dev.compose.yml`** — three core services (`postgres:17.2-alpine`, `redis:7.4-alpine`, `otel/opentelemetry-collector-contrib:0.115.0`) plus two viewers gated behind Compose profiles:
  - `--profile dbtools` → `sosedoff/pgweb:0.16.2` (Postgres GUI on port 8081)
  - `--profile observability` → `jaegertracing/all-in-one:1.62` (Jaeger UI on 16686)
  - All ports overridable via `.env`. State in named volumes. Healthchecks on data services.
- **`infra/local/.env.example`** — credentials + ports template. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` are mandatory (compose refuses to boot without them); other keys default sensibly.
- **`infra/local/init/postgres/01-init.sql`** — bootstrap SQL per **ADR-0013**: `audit_owner` / `audit_writer` / `audit_reader` / `audit_archiver` roles + `audit` schema. Default privileges encode the append-only contract (INSERT to writer, SELECT to reader, DELETE to archiver, no UPDATE/TRUNCATE to anyone). Applied on first Postgres boot only; documented re-run procedure.
- **`infra/local/otel-collector.yaml`** — pipeline: OTLP gRPC/HTTP → batch → debug exporter (always) + forward to `jaeger:4317`. When the observability profile is off, the Jaeger export logs warn-level retries but doesn't block the debug pipeline.

### Surrounding doc updates
- **`infra/README.md`** — new "Local-dev stack" section: service inventory, port table, first-time setup walkthrough, persistence/bootstrap-replay tips. The previous `local/` placeholder line is removed.
- **`docs/development.md`** §3 — rewritten to walk through the compose-based setup; cross-links to `infra/README.md` for the full reference. Roadmap entry for "Local infra recipe" removed from §8 (now implemented); "Observability dev-loop" line adjusted to point at the new Jaeger profile.

### Out of scope
- **Production parity** — HA Postgres, Redis Sentinel, real OTel backend (Tempo / Loki / etc.) — defer to the on-prem infrastructure ADR (phase 3b). The dev-only nature of this stack is called out explicitly in `infra/README.md`.
- **Wiring the BFF** to actually use these endpoints (NestJS config, Prisma datasource URL, OTel SDK init) — that's the **B — Observability foundations** chantier, next up.

## Test plan
- [ ] `cd infra/local && cp .env.example .env && docker compose -f dev.compose.yml up -d` → all three core services come up healthy; verify with `docker compose ps`.
- [ ] `psql postgres://portal:<pwd>@localhost:5432/portal_dev -c "\dn"` shows the `audit` schema; `\dg` shows the four audit roles.
- [ ] `redis-cli -a <pwd> PING` → `PONG`.
- [ ] Send a fake OTLP trace via grpcurl → see it printed by `docker compose logs otel-collector`.
- [ ] `--profile dbtools up -d` → http://localhost:8081 shows pgweb UI, can navigate to the audit schema.
- [ ] `--profile observability up -d` → http://localhost:16686 shows Jaeger UI; collector logs no longer report Jaeger export retries.
- [ ] `docker compose down -v` cleanly removes everything; next `up -d` re-runs the bootstrap SQL.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #57
2026-05-08 19:23:43 +02:00