Files
apf_portal/infra
julien a84ea2d116
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
feat(spa): proxy /api in dev-server, relative bffApiBaseUrl (#259)
## 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
..

infra/

Infrastructure-as-code artefacts for the project. Separate from application code and from documentation: this folder contains the recipes and configs that the team and ops use to stand up running infrastructure (CI runners, future local-dev databases, future on-prem deploy assets).

Subject File / Folder ADR / Reference
Self-hosted CI runners (Gitea Actions) ci-runners.compose.yml ADR-0015 §"Runners"
Shared act_runner configuration runner-config.yaml ADR-0015 §"Runners"
CI runners convenience script ci-runners.sh See "Convenience script" below
Runtime state of the runners data/ (git-ignored after .gitignore)
Env-vars template for the runners .env.example (.env is git-ignored)
Local-dev runtime stack local/ ADR-0006, ADR-0010, ADR-0012, ADR-0013
Entra group GUID → role slug map test-tenant.entra.example.json (*-tenant.entra.json is git-ignored) ADR-0025 §"Sources of truth — Entra-side configuration"

Future folders / files that will land here as the corresponding ADRs ship:

  • prod/ — On-prem deploy manifests (HA Postgres, Redis Sentinel, OTel collector + backend, secret manager). Triggered by the on-prem infrastructure ADR (phase 3b).

CI runners — ci-runners.compose.yml

Three self-hosted act_runner instances, registered with the project's Gitea organisation, labelled self-hosted + on-prem (the labels referenced by every job in .gitea/workflows/*). Three matches the floor recommended by ADR-0015 §"Runners" — one runner is enough to validate the pipeline; two leave no slack; three keep CI flowing if one runner is down for upgrade or maintenance.

First-time registration

cd infra/

# 1. Generate a registration token in Gitea.
#    Site Administration → Actions → Runners → "Create new Runner"
#    (or, for org-scoped runners: Organisation Settings → Actions → Runners).
#    The token is one-time and short-lived; don't lose it.

# 2. Configure .env (which is git-ignored).
cp .env.example .env
$EDITOR .env
#    Set GITEA_INSTANCE_URL (https, no trailing slash) and
#    GITEA_RUNNER_REGISTRATION_TOKEN.

# 3. Pre-pull the job images and bring the runners up. The script
#    chains the two — see "Job image pinning and pre-pull" below
#    for the rationale.
./ci-runners.sh up --prepull

# 4. Verify in Gitea: the three runners appear as online with the
#    self-hosted, on-prem labels. If a runner doesn't come online,
#    inspect its logs:
./ci-runners.sh logs runner-1

After the first successful boot, each runner stores its credentials under data/runner-N/.runner. The registration token is no longer needed and should be removed from .env. Subsequent restarts (./ci-runners.sh restart … or direct docker compose restart …) authenticate from the persisted credential.

Convenience script — ci-runners.sh

ci-runners.sh is a thin wrapper around docker compose -f ci-runners.compose.yml ... for the everyday verbs. Two reasons to use it:

  1. Hides the compose-file path on every command. ./ci-runners.sh up instead of docker compose -f ci-runners.compose.yml up -d.
  2. rotate automates the rolling restart the "Operational tips" below recommend: runner-1 → wait → runner-2 → wait → runner-3, so the CI pipeline always has at least N-1 runners online while you push a config change.
Command Effect
./ci-runners.sh up Bring the three runner containers up
./ci-runners.sh up --prepull Pre-pull the job images (act-22.04 + :full-22.04) on the host first
./ci-runners.sh down Stop and remove the containers (preserves data/runner-N/.runner credentials)
./ci-runners.sh restart <runner> Restart one runner
./ci-runners.sh rotate Rolling restart of every runner with a 15 s pause between each
./ci-runners.sh status docker compose ps for the runner services
./ci-runners.sh logs [runner] Follow logs (one runner or all of them)
./ci-runners.sh pull-images Pre-pull / refresh the job images (idempotent)

Anything not matching one of the named verbs is passed through to docker compose -f ci-runners.compose.yml .... Run ./ci-runners.sh help for the full reference.

For the destructive down -v (wipes data/, forces re-registration with a fresh Gitea token), the script intentionally doesn't offer a verb — invoke docker compose -f ci-runners.compose.yml down -v directly so the path is explicit at the typing level.

Operational tips

  • Rotation of one runner at a time — to upgrade the image or change config, run ./ci-runners.sh rotate (or restart manually one by one — ./ci-runners.sh restart runner-1, wait, …) so the CI pipeline is never starved.
  • Logs./ci-runners.sh logs runner-N (or docker compose logs -f --tail=100 runner-N) for a single runner; jobs being executed appear here.
  • Disk pressure — the runner caches each job's container image in /var/lib/docker on the host. On a small host, prune periodically (docker system prune -af while no job is running).
  • Adding a fourth runner — copy any runner-N block in the compose file, increment the suffix in container_name, GITEA_RUNNER_NAME, and the data/ mount path. Add the new name to the RUNNERS=(…) array at the top of ci-runners.sh so rotate and restart learn about it. Then ./ci-runners.sh up (or docker compose up -d). The runner registers using the same GITEA_RUNNER_REGISTRATION_TOKEN (which must be regenerated if it has expired).

Security — Docker socket exposure

The compose mounts /var/run/docker.sock into each runner so jobs can spawn containers. This grants the runner root-equivalent access to the host's Docker daemon. A malicious workflow could spawn arbitrary containers, mount host paths, escalate privileges. Mitigations:

  • Trust boundary: only register the runners against repositories controlled by the org. Gitea's runner-registration UI lets you scope a runner to an organisation, a single repository, or instance-wide. Prefer the narrowest scope.
  • Dedicated host: run these containers on a host that does not also run production services or hold sensitive data. The runner host is in the trust boundary of any developer who can push to a repo it serves.
  • No host filesystem mounts beyond the docker socket: the compose intentionally does not mount /, /etc, or any project source. Workflows that need data on the host must do so via Docker volumes.
  • Future hardening (out of scope of v1): migrate to rootless Docker on the runner host, or to a DinD (Docker-in-Docker) sidecar so the runner cannot escape into the host daemon. Decided when the org's RSSI confirms the security posture, or when the runner host is shared with anything else of value.

Cache server

act_runner ships a built-in GitHub-Actions-cache-compatible server, used by actions/setup-node@v6 (cache: 'pnpm'), actions/cache, and similar. The default behaviour does not work in our compose-based setup: the runner container is on the compose-defined apf-portal-act-runners bridge, while jobs spawned through the mounted /var/run/docker.sock come up on Docker's anonymous bridge network — the cache server binds inside the runner on a random port, advertises an IP on the runners' bridge, and the job can't reach it. The symptom is a ~2 min ETIMEDOUT at the start (restore) and end (save) of every job that opts into caching.

The fix is in runner-config.yaml: container.network: apf-portal-act-runners instructs act_runner to attach every job container to the same compose-defined bridge as the runners. Job → runner is now an internal-network DNS hop, the advertised cache URL is reachable, and cache: 'pnpm' works end-to-end. The cache: 'pnpm' flag is enabled on every actions/setup-node step in .gitea/workflows/ci.yml and .gitea/workflows/security-scheduled.yml.

The blast-radius trade-off is bounded: every container on apf-portal-act-runners is one of our runner containers (plus the jobs they spawn), all of which already have full docker-socket access. Sharing a network does not widen what a malicious workflow can already do; it just lets jobs reach the cache server.

If the cache ever needs to be disabled (debugging cache-hit issues, etc.), set cache.enabled: false in runner-config.yaml and ./ci-runners.sh rotate.

act_runner image pinning

The compose pins gitea/act_runner:0.2.13. Update the pin deliberately, not via :latest:

  1. Read the act_runner release notes for breaking changes.
  2. Edit the three image references (runner-1, runner-2, runner-3).
  3. Commit on a feature branch with a chore(deps): Conventional Commits subject.
  4. Roll one runner at a time (rotation tip above).

The matching CI workflows refer to runner labels (not images), so a runner-image upgrade does not affect .gitea/workflows/*.

Job image pinning and pre-pull

act_runner runs each job inside a container whose image is selected by the runner's labels. Two images are in use:

Label Image Used by
self-hosted catthehacker/ubuntu:act-22.04 check, scan, commits, a11y
on-prem catthehacker/ubuntu:act-22.04 (alias of self-hosted)
(per-job container:) catthehacker/ubuntu:full-22.04 perf (Lighthouse needs Chrome)

runner-config.yaml sets container.force_pull: false. Without that, act_runner re-issues a docker pull at the start of every single job (~1030 s of registry round-trip even when every layer is already cached), which both wastes wall-clock and contradicts our policy of upgrading job images deliberately rather than implicitly via :latest.

The trade-off: the host Docker daemon must already hold the images locally. Pre-pull them once after a fresh runner host install:

docker pull catthehacker/ubuntu:act-22.04
docker pull catthehacker/ubuntu:full-22.04

Upgrading to a newer tag is a deliberate three-step process:

  1. Edit GITEA_RUNNER_LABELS (in ci-runners.compose.yml) and / or the per-job container.image: (in .gitea/workflows/*) to the new tag.
  2. On the runner host, docker pull <new-tag> so the image is locally available before the next CI job starts.
  3. Commit on a feature branch with a chore(deps): Conventional Commits subject; one of chore(deps): upgrade CI job image to ....

Old, no-longer-referenced images can be reaped during the periodic docker system prune -af (see "Disk pressure" above).


Local-dev stack — local/

A Docker Compose recipe spinning up the runtime services the BFF and ADRs assume — Postgres, Redis, OpenTelemetry Collector — plus optional viewers / tooling (pgweb, Jaeger UI, Caddy serve-static) gated behind Compose profiles. Designed to start in a single command on a contributor's WSL2 / Linux / macOS host.

File Role
local/dev.sh Convenience wrapper around docker compose — see "Convenience script" below
local/dev.compose.yml Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy / the apps dev servers behind profiles
local/Dockerfile.dev Dev-only image (Node 24 + corepack) shared by the three apps-profile dev servers (ADR-0030)
local/dev-entrypoint.sh Entrypoint for the apps services: BFF runs prisma generate + migrate deploy, then each runs nx serve
local/.env.example Credentials + ports template (copy to .env, which is git-ignored)
local/init/postgres/01-init.sql Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only
local/otel-collector.yaml Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active
local/Caddyfile Reverse-proxy config for the serve-static profile — per-locale SPA fallback + smart / redirect (ADR-0019)

First-time setup

# 1. Configure local secrets (copy template, edit, do not commit).
cp infra/local/.env.example infra/local/.env
$EDITOR infra/local/.env
#    Set strong dev values for POSTGRES_PASSWORD and REDIS_PASSWORD
#    (defaults in the template are placeholders that the compose
#    rejects with `must be set in infra/local/.env` if left as-is).

# 2. Bring up the core stack (postgres + redis + otel-collector).
./infra/local/dev.sh up

# 3. (Optional) Activate viewers / tooling when needed:
./infra/local/dev.sh up dbtools         # adds pgweb
./infra/local/dev.sh up observability   # adds Jaeger UI
./infra/local/dev.sh up serve-static    # adds caddy serving the prod build
./infra/local/dev.sh up all             # core + every profile

# 4. Verify health.
./infra/local/dev.sh status

Convenience script — dev.sh

local/dev.sh is a thin wrapper around docker compose -f dev.compose.yml ... with two reasons to exist:

  1. Hides the Compose-profile gotcha. docker compose down only operates on services whose profile is currently active — anything started under --profile X keeps running unless the same flag is on down. The script always passes every profile in scope on teardown / status / log commands, so profile-gated services (pgweb, Jaeger) are never accidentally orphaned.
  2. Ergonomic verbs for the common workflows. ./dev.sh up all, ./dev.sh stop pgweb, ./dev.sh logs otel-collector, etc.

Run ./infra/local/dev.sh help for the full reference. Cheat-sheet:

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 up serve-static Core + Caddy serving dist/.../browser/ per ADR-0019
./infra/local/dev.sh up apps Core + the three Nx dev servers in Docker (ADR-0030)
./infra/local/dev.sh down Tear down the whole stack (every profile in scope)
./infra/local/dev.sh down -v Tear down + wipe named volumes (incl. audit-roles bootstrap)
./infra/local/dev.sh stop pgweb Stop one service (containers stay around)
./infra/local/dev.sh status docker compose ps, with every profile visible
./infra/local/dev.sh logs otel-collector Follow logs
./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" Run a command inside a service

Anything not matching one of the named verbs is passed through to docker compose -f dev.compose.yml ... (with every profile flagged in), so you keep the full Compose surface available — ./dev.sh config, ./dev.sh top, ./dev.sh inspect …, etc.

If you prefer to call docker compose directly, every example below shows the raw command alongside the script form.

Dockerised app dev mode — apps profile (ADR-0030)

The apps profile runs the three Nx dev servers in Docker, so a contributor can bring up the whole stack without installing Node / pnpm natively:

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

How it works (see ADR-0030):

  • A single Dockerfile.dev (Node 24 + corepack) backs all three services — one image, one install for the monorepo.
  • The repo is bind-mounted for hot reload; node_modules and the Nx cache live in named volumes (apf-portal-app-node-modules, apf-portal-app-nx-cache) so the container's native modules are never shadowed by the host's.
  • A one-shot apps-deps service runs pnpm install once into the shared volume; the three servers gate on its completion, avoiding a three-way install race.
  • The BFF entrypoint runs prisma generate + prisma migrate deploy before serving.

Prerequisite — the BFF still needs its secrets. No native toolchain is required, but apps/portal-bff/.env (Entra / session / jwks config) must exist, same as native dev (cp apps/portal-bff/.env.example apps/portal-bff/.env then fill it). The host-specific URLs (DATABASE_URL / REDIS_URL / OTel endpoint) are overridden automatically to the Compose service names — you don't edit those for the container. SPA-only work (up portal-shell) doesn't need the BFF env.

Port note. The SPA dev servers default to 4200 / 4300 — 4200 is the same port the serve-static profile uses. Don't run apps and serve-static together, or set SHELL_PORT in infra/local/.env.

The three dev modes (native nx serve, devcontainer, this apps profile) and when to use each are summarised in docs/setup/01-dev-debian-vm-setup.md.

Service endpoints (defaults)

Service Host port Purpose
Postgres 5432 DB connection — postgres://portal:<pwd>@localhost:5432/portal_dev
Redis 6379 Sessions, OBO cache (per ADR-0010 / ADR-0014)
OTel Collector gRPC 4317 OTEL_EXPORTER_OTLP_ENDPOINT for the BFF and the SPA
OTel Collector HTTP 4318 OTLP/HTTP variant
pgweb (profile) 8081 http://localhost:8081 — Postgres GUI
Jaeger UI (profile) 16686 http://localhost:16686 — trace explorer
Caddy serve-static (profile) 4200 http://localhost:4200/ — production build with per-locale routing (/fr/, /en/) + smart / redirect, per ADR-0019. Run pnpm exec nx build portal-shell --configuration=production first or the proxy will 404 everything.

All ports are overridable via .env if the host machine has conflicts.

Operational tips

  • Persistence — state lives in named Docker volumes (apf-portal-postgres-data, apf-portal-redis-data). Survives docker compose down. Use docker compose -f dev.compose.yml down -v to wipe (also wipes the audit-roles bootstrap, which re-runs on the next fresh boot).

  • Profile symmetrydev.sh down (and status, logs, …) always include every profile in scope, so profile-gated services are caught. If you bypass the script and call docker compose down directly, you must pass the same --profile flags as on up, otherwise pgweb and Jaeger keep running silently. Either pass them again, or export COMPOSE_PROFILES=dbtools,observability in your shell or infra/local/.env.

  • Bootstrap re-run — the SQL in local/init/postgres/ only runs on a fresh Postgres data volume. To replay after editing the file, down -v (loses all dev data) or run the SQL manually with docker compose exec postgres psql -U portal -d portal_dev -f /docker-entrypoint-initdb.d/01-init.sql.

  • Logsdocker compose -f dev.compose.yml logs -f <service> to follow a single service. otel-collector is the loudest — its debug exporter prints every span / metric / log it receives.

  • Image upgrades — same policy as the runner image (deliberate, not via :latest). Renovate's docker-compose manager will surface bumps automatically once the dashboard rule allows them.

Production parity

This stack is dev-only. The corresponding production layout (HA Postgres, Redis Sentinel cluster, OTel Collector with a real backend, secret manager) lives in the future on-prem-infrastructure ADR — see prod/ placeholder below.


Entra group map — test-tenant.entra.example.json

Pure JSON object keyed on Entra security-group GUID (lower-case), valued by an apf-role-<slug> slug from the ADR-0025 functional-role catalogue. The BFF loads it at boot through EntraGroupToRoleResolver (from shared-auth) and uses it on every sign-in to translate the groups claim into the 24-entry catalogue's role slugs.

The 24 entries below cover the entire v1 catalogue — including partenaire, which ships empty in the test tenant by design but is kept in the schema so a typo or omission fails the parser at boot rather than silently dropping the role.

Provisioning a real file

cp infra/test-tenant.entra.example.json infra/test-tenant.entra.json

# Then for each role replace the placeholder GUID with the real one
# from Entra:
#   Microsoft Entra admin centre → Groups → <apf-role-*> → Object ID.
# Point the BFF at the file via apps/portal-bff/.env:
#   ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json

The real file (infra/<env>-tenant.entra.json) is git-ignored because the group GUIDs are tenant-private — leaking them does not authorize anything by itself, but it does reveal the tenant's internal authorization topology. Each environment (test / preprod / prod) carries its own file; the slugs are stable across environments, the GUIDs are not.

If ENTRA_GROUP_MAP_PATH is unset, the resolver runs with an empty map: every user signs in successfully but receives an empty roles[] (and consequently no apf-role-* UI). The BFF logs a WARN at boot so an operator can spot the missing config; this is a deliberate fail-soft posture so a fresh dev environment is not blocked by an Entra-side dependency.

Validation rules enforced at boot by parseEntraGroupMap (in libs/shared/auth/):

  • keys must look like a GUID (8-4-4-4-12 hex);
  • values must be members of FUNCTIONAL_ROLES;
  • the same GUID cannot map to two different slugs (case-insensitive).

A malformed file crashes the BFF at startup. The error message names the offending key / value.


Future infra concerns — placeholders

These are listed here so a contributor knows where to expect related files; they don't exist yet.

File Purpose Triggered by
prod/* On-prem deployment manifests (k8s, Compose, or whatever the on-prem infra ADR settles on) The on-prem infrastructure ADR (phase 3b)
runbooks/*.md Operational runbooks (incident response, secret rotation, runner upgrade procedure, …) First incident, or when ops cadence justifies them