Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Gautier 8dd235e44f feat(spa): proxy /api in dev-server, relative bffApiBaseUrl
CI / check (pull_request) Successful in 4m8s
CI / commits (pull_request) Successful in 4m12s
CI / scan (pull_request) Successful in 5m6s
CI / a11y (pull_request) Successful in 1m47s
CI / perf (pull_request) Successful in 6m21s
Make the SPAs reach the BFF same-origin via the Angular dev-server's
proxy, so the dockerised dev mode (ADR-0030) works when the SPA is
accessed from a remote browser (e.g. http://<vm-ip>:4200/) — and CORS
is bypassed in dev altogether.

- New proxy.conf.js per SPA: /api -> ${BFF_TARGET:-http://localhost:3000}
  (JS form so the env var can swap the target at startup without a
  rebuild).
- project.json serve.options.proxyConfig wired in for both apps.
- environment.ts (shell + admin) bffApiBaseUrl: 'http://localhost:3000/api'
  -> '/api'. Production siblings can still set an absolute origin if
  the SPA and BFF live on different hosts; tracing.ts resolves either
  form against window.location.origin.
- tracing.ts: new URL(env, window.location.origin) — relative bases no
  longer throw, absolute bases keep their own origin.
- dev.compose.yml: BFF_TARGET=http://portal-bff:3000 on portal-shell
  and portal-admin so the proxy hits the BFF container by Compose DNS.
  Native nx serve leaves it unset and falls back to localhost.

OTel exporter URL and cross-SPA links remain absolute — same remote-
browser issue, but not blocking the 'Backend unreachable' path this
PR targets. Out of scope here, follow-up if needed.

Stacked on top of feat/dockerised-dev-mode.
2026-06-01 11:16:05 +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
16 changed files with 315 additions and 26 deletions
+3 -2
View File
@@ -57,12 +57,13 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md). - **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 23-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md). - **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 23-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md).
- **Runtime:** Node.js latest LTS major. - **Runtime:** Node.js latest LTS major.
- **Local dev environment:** three coexisting run modes — native `pnpm nx serve`, the VSCode devcontainer, and a Docker Compose `apps` profile that boots all three Nx dev servers without a native Node/pnpm toolchain (`./infra/local/dev.sh up apps`). Dev-only (production images deferred to the ADR-0028 Container Registry work); shared `Dockerfile.dev`, repo bind-mounted for hot reload, `node_modules`/Nx cache in named volumes, BFF entrypoint runs `prisma generate` + `migrate deploy` — see [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md).
## Repository status ## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR. The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR.
ADRs 0001 → 0028 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, and CI/CD platform migration choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:** ADRs 0001 → 0028 plus ADR-0030 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, CI/CD platform migration, and dockerised local-dev mode choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition — its number is reserved ahead of ADR-0030, which was written first. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:**
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI. - **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)). - **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
@@ -109,7 +110,7 @@ pnpm nx format:check
## Environment conventions ## Environment conventions
- **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md). - **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md).
- **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). - **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). Independently of the IDE flow, the apps can run **natively** (`pnpm nx serve`) or as **Docker Compose services** (`./infra/local/dev.sh up apps`, no native toolchain — ADR-0030); the "which mode when" table is in [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md).
- **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project. - **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project.
- **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts". - **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts".
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles. - **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
+2 -1
View File
@@ -84,7 +84,8 @@
"continuous": true, "continuous": true,
"executor": "@angular/build:dev-server", "executor": "@angular/build:dev-server",
"options": { "options": {
"port": 4300 "port": 4300,
"proxyConfig": "apps/portal-admin/proxy.conf.js"
}, },
"configurations": { "configurations": {
"production": { "production": {
+21
View File
@@ -0,0 +1,21 @@
// Angular dev-server proxy for portal-admin.
//
// Mirrors apps/portal-shell/proxy.conf.js — same rationale, same
// `/api → ${BFF_TARGET:-http://localhost:3000}` rule. The admin app
// talks to the same BFF (ADR-0020 §"Where does the admin app live"),
// just at admin-specific paths under `/api/admin/...`; the proxy
// match on `/api` covers both surfaces.
//
// JS form deliberate — only this form can read `process.env` so the
// Docker / native target swap (BFF_TARGET in dev.compose.yml) works
// without a rebuild.
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
@@ -13,13 +13,17 @@
*/ */
export const environment = { export const environment = {
/** /**
* Origin + prefix of the BFF HTTP API. Same value as portal-shell * Prefix of the BFF HTTP API. Same value as portal-shell — both
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does * SPAs talk to the same BFF (per ADR-0020 §"Where does the admin
* the admin app live"). The admin-specific routing happens via * app live"). The admin-specific routing happens via the
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in * `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* `app.config.ts`, not by talking to a different host. * `app.config.ts`, not by talking to a different host.
*
* Relative path: see portal-shell `environment.ts` for the full
* rationale. Both SPAs use `proxy.conf.js` to proxy `/api/*` to
* the BFF, keeping every call same-origin in the browser.
*/ */
bffApiBaseUrl: 'http://localhost:3000/api', bffApiBaseUrl: '/api',
/** /**
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf` * Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
+3
View File
@@ -83,6 +83,9 @@
"serve": { "serve": {
"continuous": true, "continuous": true,
"executor": "@angular/build:dev-server", "executor": "@angular/build:dev-server",
"options": {
"proxyConfig": "apps/portal-shell/proxy.conf.js"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "portal-shell:build:production" "buildTarget": "portal-shell:build:production"
+32
View File
@@ -0,0 +1,32 @@
// Angular dev-server proxy for portal-shell.
//
// Lets the SPA call `/api/...` as a SAME-ORIGIN request — the dev
// server intercepts it and proxies to the BFF. Two wins:
// - the browser no longer pins the BFF to `localhost:3000`, so the
// SPA works when accessed from a different host (e.g. `http://
// <vm-ip>:4200/` in the ADR-0030 dockerised dev mode, where the
// browser may not be on the same machine as the BFF);
// - CORS is bypassed entirely in dev (same origin), so the BFF's
// `CORS_ALLOWED_ORIGINS` allowlist no longer has to enumerate the
// workstation/VM hostnames a developer might use.
//
// Target resolution:
// - native `nx serve` → defaults to http://localhost:3000
// (the BFF on the same machine).
// - Compose `apps` profile → BFF_TARGET=http://portal-bff:3000 is
// set in dev.compose.yml so the proxy
// hits the BFF container by name.
//
// JS form (not JSON) is deliberate: it is the only Angular-supported
// proxy-config form that can read `process.env` at dev-server startup,
// which is what makes the Docker / native swap work without rebuilds.
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
@@ -18,12 +18,24 @@
*/ */
export const environment = { export const environment = {
/** /**
* Origin + prefix of the BFF HTTP API. The SPA prepends this to * Prefix of the BFF HTTP API. The SPA prepends this to every
* every backend call (`${bffApiBaseUrl}/health`, etc.) and derives * backend call (`${bffApiBaseUrl}/health`, etc.) and derives the
* the OTel trace-header propagation pattern from its origin (see * OTel trace-header propagation pattern from its resolved origin
* `observability/tracing.ts`). * (see `observability/tracing.ts`).
*
* Relative path: the Angular dev-server proxies `/api/*` to the
* BFF (see `proxy.conf.js`, BFF target overridable via the
* `BFF_TARGET` env var — set by the ADR-0030 `apps` Compose
* profile to `http://portal-bff:3000`). This keeps every BFF call
* same-origin in the browser, so the SPA works whether it is
* accessed via `localhost:4200`, the VM IP, or any other host —
* and the BFF's `CORS_ALLOWED_ORIGINS` no longer has to enumerate
* every developer-side hostname. Production siblings
* (`environment.prod.ts`, etc.) may set an absolute origin when
* the SPA and BFF live on different hosts; `tracing.ts` resolves
* either form against `window.location.origin`.
*/ */
bffApiBaseUrl: 'http://localhost:3000/api', bffApiBaseUrl: '/api',
/** /**
* Name of the BFF's CSRF cookie. Mirrors the BFF's * Name of the BFF's CSRF cookie. Mirrors the BFF's
@@ -55,7 +55,13 @@ const SERVICE_VERSION = 'dev';
// so a deploy-time change to `bffApiBaseUrl` automatically propagates // so a deploy-time change to `bffApiBaseUrl` automatically propagates
// `traceparent` to the right origin. RegExp special chars are escaped // `traceparent` to the right origin. RegExp special chars are escaped
// before going into the source. // before going into the source.
const bffOrigin = new URL(environment.bffApiBaseUrl).origin; //
// Resolved against `window.location.origin` so a relative
// `bffApiBaseUrl` (e.g. `/api` for the dev-server proxy in
// `proxy.conf.js`) yields the current origin; an absolute
// `bffApiBaseUrl` (e.g. cross-origin production) keeps its own origin
// (the second `URL` arg is ignored when the first is absolute).
const bffOrigin = new URL(environment.bffApiBaseUrl, window.location.origin).origin;
const bffOriginRegex = new RegExp(`^${bffOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/.*`); const bffOriginRegex = new RegExp(`^${bffOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/.*`);
const provider = new WebTracerProvider({ const provider = new WebTracerProvider({
+2 -2
View File
@@ -1,5 +1,5 @@
--- ---
status: proposed status: accepted
date: 2026-05-28 date: 2026-05-28
decision-makers: R&D Lead decision-makers: R&D Lead
tags: [infrastructure, process] tags: [infrastructure, process]
@@ -109,4 +109,4 @@ The follow-up PR documents this table in `docs/setup/`:
- The BFF entrypoint's `prisma generate` + `migrate deploy` follows [ADR-0006](0006-persistence-postgresql-prisma.md); migrations are applied, never authored, inside the container. - The BFF entrypoint's `prisma generate` + `migrate deploy` follows [ADR-0006](0006-persistence-postgresql-prisma.md); migrations are applied, never authored, inside the container.
- Production images are **out of scope** and tracked against the [ADR-0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) Container Registry follow-up (post-cutover). - Production images are **out of scope** and tracked against the [ADR-0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) Container Registry follow-up (post-cutover).
- Builds on the existing [`infra/local/dev.compose.yml`](../../infra/local/dev.compose.yml) profiles pattern (`dbtools`, `observability`, `serve-static`) — `apps` is one more profile in the same idiom. - Builds on the existing [`infra/local/dev.compose.yml`](../../infra/local/dev.compose.yml) profiles pattern (`dbtools`, `observability`, `serve-static`) — `apps` is one more profile in the same idiom.
- Status is `proposed`; on acceptance, update the CLAUDE.md architecture roll-up and add the "which mode when" guidance to `docs/setup/`. - Accepted; the implementation PR carries the `Dockerfile.dev`, the `apps` Compose profile, the BFF entrypoint, the CLAUDE.md architecture roll-up entry, and the "which mode when" guidance in `docs/setup/`.
+1 -1
View File
@@ -72,4 +72,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account — portal-side identity model | accepted | `data`, `backend`, `security` | 2026-05-24 | | [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account — portal-side identity model | accepted | `data`, `backend`, `security` | 2026-05-24 |
| [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | accepted | `data`, `backend` | 2026-05-24 | | [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | accepted | `data`, `backend` | 2026-05-24 |
| [0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) | Migrate CI/CD + git hosting from Gitea to GitLab self-hosted | accepted | `infrastructure`, `process` | 2026-05-26 | | [0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) | Migrate CI/CD + git hosting from Gitea to GitLab self-hosted | accepted | `infrastructure`, `process` | 2026-05-26 |
| [0030](0030-dockerised-dev-mode.md) | Dockerised full-stack dev mode — `compose up` runs the Nx apps alongside infra | proposed | `infrastructure`, `process` | 2026-05-28 | | [0030](0030-dockerised-dev-mode.md) | Dockerised full-stack dev mode — `compose up` runs the Nx apps alongside infra | accepted | `infrastructure`, `process` | 2026-05-28 |
+18
View File
@@ -203,6 +203,24 @@ pnpm exec nx run-many -t lint test --parallel=3 # smoke test
## Step 5 — Choose your IDE flow ## Step 5 — Choose your IDE flow
### Running the apps — three modes (which when)
There are three ways to run the apps locally. They coexist; pick by need.
| Mode | Toolchain on host | Best for |
| --------------------------------- | ------------------ | ----------------------------------------------------------------------------------------- |
| **Native** (`pnpm nx serve`) | Node + pnpm native | Day-to-day dev — fastest iteration, simplest debugger attach. |
| **Devcontainer** (Option B below) | none (Docker) | IDE-integrated dev with no native toolchain; VSCode attaches, you run `nx serve` inside. |
| **Compose `apps` profile** | none (Docker) | "Run everything with one command" — onboarding, frontend-only work, demos. No IDE attach. |
The Compose `apps` profile (per [ADR-0030](../decisions/0030-dockerised-dev-mode.md)) is the lightest way to boot the full stack without a native toolchain:
```bash
./infra/local/dev.sh up apps # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```
Usage, prerequisites (the BFF still needs its `apps/portal-bff/.env` secrets) and the port caveat are documented in [infra/README.md](../../infra/README.md) → "Dockerised app dev mode". The IDE-flow options below (Remote-SSH / Devcontainer / Hybrid) are orthogonal — they decide where your editor + terminals run, not how the apps boot.
### Option A — VSCode Remote-SSH (default) ### Option A — VSCode Remote-SSH (default)
From your workstation: From your workstation:
+32 -8
View File
@@ -143,14 +143,16 @@ Old, no-longer-referenced images can be reaped during the periodic `docker syste
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. 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 | | File | Role |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below | | [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below |
| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy behind profiles | | [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy / the `apps` dev servers behind profiles |
| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) | | [`local/Dockerfile.dev`](local/Dockerfile.dev) | Dev-only image (Node 24 + corepack) shared by the three `apps`-profile dev servers (ADR-0030) |
| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only | | [`local/dev-entrypoint.sh`](local/dev-entrypoint.sh) | Entrypoint for the `apps` services: BFF runs `prisma generate` + `migrate deploy`, then each runs `nx serve` |
| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active | | [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) |
| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) | | [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only |
| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active |
| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) |
### First-time setup ### First-time setup
@@ -191,6 +193,7 @@ Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet:
| `./infra/local/dev.sh up dbtools` | Core + pgweb | | `./infra/local/dev.sh up dbtools` | Core + pgweb |
| `./infra/local/dev.sh up observability` | Core + Jaeger | | `./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 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` | 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 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 stop pgweb` | Stop one service (containers stay around) |
@@ -202,6 +205,27 @@ Anything not matching one of the named verbs is passed through to `docker compos
If you prefer to call `docker compose` directly, every example below shows the raw command alongside the script form. 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:
```bash
./infra/local/dev.sh up apps # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```
How it works (see [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md)):
- A single [`Dockerfile.dev`](local/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](../docs/setup/01-dev-debian-vm-setup.md).
### Service endpoints (defaults) ### Service endpoints (defaults)
| Service | Host port | Purpose | | Service | Host port | Purpose |
+35
View File
@@ -0,0 +1,35 @@
# Dev-only image for the dockerised full-stack dev mode (ADR-0030).
#
# One image serves all three Nx apps (portal-bff / portal-shell /
# portal-admin) — the monorepo means a single install. The image is
# intentionally minimal: Node + corepack, nothing copied in. The repo
# is bind-mounted at runtime and dependencies install into a named
# volume (see dev.compose.yml `apps` profile), so node_modules — with
# its native modules built for THIS image — is never shadowed by the
# host's.
#
# NOT a production image. Production artefacts are out of scope per
# ADR-0030 and tracked against the ADR-0028 Container Registry work.
FROM node:24-bookworm
# corepack ships with Node 24. Enabling it lets pnpm resolve from the
# `packageManager` field in package.json at runtime — no version pinned
# here, so this image never drifts from the repo's pinned pnpm.
RUN corepack enable
# Nx + Angular CLI memory ceiling — matches the devcontainer (.devcontainer/
# devcontainer.json). 4 GB avoids OOM on large `nx` graph work.
ENV NODE_OPTIONS=--max-old-space-size=4096
# Nx daemon is per-container and would contend across the three app
# containers sharing one workspace bind-mount — disable it. Slight
# task-graph cost, robust behaviour.
ENV NX_DAEMON=false
WORKDIR /workspace
# No COPY / no install at build time: the repo is bind-mounted and the
# `apps-deps` one-shot service installs into the node_modules volume on
# first boot. Build context stays tiny (just infra/local/) — nothing is
# copied. Invoked via `bash` so it does not depend on the bind-mounted
# script keeping its executable bit.
ENTRYPOINT ["bash", "/workspace/infra/local/dev-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Shared entrypoint for the ADR-0030 `apps` Compose profile services.
#
# Behaviour is driven by env + the passed command:
# - The `apps-deps` one-shot service runs `pnpm install` as its
# command; this entrypoint just execs it (APF_ROLE unset → no
# prisma). It populates the shared node_modules volume once, so
# the app services don't race three concurrent installs.
# - The BFF service sets APF_ROLE=bff → this entrypoint runs
# `prisma generate` + `prisma migrate deploy` (client + committed
# migrations) before serving. `migrate deploy`, never `migrate
# dev` — a container never authors migrations.
# - The SPA services (portal-shell / portal-admin) leave APF_ROLE
# unset → straight to `exec "$@"` (nx serve).
set -euo pipefail
cd /workspace
if [[ "${APF_ROLE:-}" == "bff" ]]; then
echo "[dev-entrypoint] BFF: prisma generate"
pnpm exec prisma generate
echo "[dev-entrypoint] BFF: prisma migrate deploy"
pnpm exec prisma migrate deploy
fi
echo "[dev-entrypoint] exec: $*"
exec "$@"
+101
View File
@@ -34,6 +34,23 @@
name: apf-portal-dev name: apf-portal-dev
# Shared base for the ADR-0030 `apps` profile — dev servers for the three
# Nx apps. The repo is bind-mounted for hot reload; node_modules and the
# Nx cache live in named volumes so the container's install (native
# modules built for THIS image) is never shadowed by the host's.
x-app-base: &app-base
build:
context: .
dockerfile: Dockerfile.dev
profiles: [apps]
working_dir: /workspace
volumes:
- ../../:/workspace
- app-node-modules:/workspace/node_modules
- app-nx-cache:/workspace/.nx
networks:
- apf-portal-dev
services: services:
postgres: postgres:
image: postgres:17.2-alpine image: postgres:17.2-alpine
@@ -189,11 +206,95 @@ services:
networks: networks:
- apf-portal-dev - apf-portal-dev
# ------------------------------------------------------------------
# ADR-0030 dockerised full-stack dev mode (`--profile apps`).
#
# ./infra/local/dev.sh up apps → infra + the three dev servers,
# no native Node/pnpm on the host.
#
# Hot reload via the repo bind-mount. See docs/setup/ for the
# "which mode when" guidance (native / devcontainer / compose apps).
# NOTE: the SPA dev servers default to 4200/4300 — the same 4200 the
# `serve-static` profile uses; don't run `apps` and `serve-static`
# together, or override SHELL_PORT.
# ------------------------------------------------------------------
# One-shot: populate the shared node_modules volume once so the three
# app services don't race three concurrent installs on it. Exits when
# done; the apps gate on its successful completion.
apps-deps:
<<: *app-base
container_name: apf-portal-apps-deps
command: ['pnpm', 'install', '--frozen-lockfile']
portal-bff:
<<: *app-base
container_name: apf-portal-bff-dev
environment:
APF_ROLE: bff
NODE_ENV: development
PORT: '3000'
# Host-specific URLs rebuilt from infra/local/.env creds → Compose
# service names. Compose `environment` wins over `env_file`, so
# these override the localhost values in the BFF's own .env.
DATABASE_URL: 'postgresql://${POSTGRES_USER:-portal}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-portal_dev}?schema=public'
REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0'
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318/v1/traces'
# Secrets (Entra / session / jwks) come from the BFF's own dev env.
# `required: false` so SPA-only devs can `up` without it — the BFF
# then fails its own boot-time config validators with a clear message
# rather than Compose erroring on a missing file.
env_file:
- path: ../../apps/portal-bff/.env
required: false
command: ['pnpm', 'exec', 'nx', 'serve', 'portal-bff']
ports:
- '${BFF_PORT:-3000}:3000'
depends_on:
apps-deps:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
portal-shell:
<<: *app-base
container_name: apf-portal-shell-dev
environment:
# Read by apps/portal-shell/proxy.conf.js — points the dev-server
# /api proxy at the BFF container by name (Compose DNS). Native
# `nx serve` leaves BFF_TARGET unset and falls back to localhost.
BFF_TARGET: http://portal-bff:3000
command: ['pnpm', 'exec', 'nx', 'serve', 'portal-shell', '--host', '0.0.0.0', '--port', '4200']
ports:
- '${SHELL_PORT:-4200}:4200'
depends_on:
apps-deps:
condition: service_completed_successfully
portal-admin:
<<: *app-base
container_name: apf-portal-admin-dev
environment:
# See portal-shell — same proxy target for the admin SPA.
BFF_TARGET: http://portal-bff:3000
command: ['pnpm', 'exec', 'nx', 'serve', 'portal-admin', '--host', '0.0.0.0', '--port', '4300']
ports:
- '${ADMIN_PORT:-4300}:4300'
depends_on:
apps-deps:
condition: service_completed_successfully
volumes: volumes:
postgres-data: postgres-data:
name: apf-portal-postgres-data name: apf-portal-postgres-data
redis-data: redis-data:
name: apf-portal-redis-data name: apf-portal-redis-data
app-node-modules:
name: apf-portal-app-node-modules
app-nx-cache:
name: apf-portal-app-nx-cache
networks: networks:
apf-portal-dev: apf-portal-dev:
+6 -1
View File
@@ -16,7 +16,7 @@ COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml"
# Profiles defined in dev.compose.yml. Keep in sync if a new profile # Profiles defined in dev.compose.yml. Keep in sync if a new profile
# is added. # is added.
ALL_PROFILES=(dbtools observability serve-static) ALL_PROFILES=(dbtools observability serve-static apps)
# Build "--profile p1 --profile p2 …" as separate arguments. # Build "--profile p1 --profile p2 …" as separate arguments.
build_all_profile_flags() { build_all_profile_flags() {
@@ -60,6 +60,10 @@ Commands:
dbtools core + pgweb dbtools core + pgweb
observability core + jaeger observability core + jaeger
serve-static core + caddy (production-build reverse proxy) serve-static core + caddy (production-build reverse proxy)
apps core + the three Nx dev servers in
Docker — no native Node/pnpm needed
(ADR-0030). portal-bff:3000,
portal-shell:4200, portal-admin:4300.
Multiple targets allowed (e.g. `up dbtools observability`). Multiple targets allowed (e.g. `up dbtools observability`).
down [-v] Tear the stack down. Always runs with every down [-v] Tear the stack down. Always runs with every
@@ -87,6 +91,7 @@ Commands:
Examples: Examples:
./infra/local/dev.sh up ./infra/local/dev.sh up
./infra/local/dev.sh up all ./infra/local/dev.sh up all
./infra/local/dev.sh up apps
./infra/local/dev.sh up observability ./infra/local/dev.sh up observability
./infra/local/dev.sh down -v ./infra/local/dev.sh down -v
./infra/local/dev.sh stop pgweb ./infra/local/dev.sh stop pgweb