From a84ea2d116c15b8c8d1a5e5cfde4c6f339a1f429 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 1 Jun 2026 11:39:40 +0200 Subject: [PATCH] feat(spa): proxy /api in dev-server, relative bffApiBaseUrl (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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://: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://: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://: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://: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 Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/259 --- apps/portal-admin/project.json | 3 +- apps/portal-admin/proxy.conf.js | 21 ++++++++++++ .../src/environments/environment.ts | 14 +++++--- apps/portal-shell/project.json | 3 ++ apps/portal-shell/proxy.conf.js | 32 +++++++++++++++++++ .../src/environments/environment.ts | 22 ++++++++++--- .../portal-shell/src/observability/tracing.ts | 8 ++++- infra/local/dev.compose.yml | 8 +++++ 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 apps/portal-admin/proxy.conf.js create mode 100644 apps/portal-shell/proxy.conf.js diff --git a/apps/portal-admin/project.json b/apps/portal-admin/project.json index 37b54e3..4fd72a7 100644 --- a/apps/portal-admin/project.json +++ b/apps/portal-admin/project.json @@ -84,7 +84,8 @@ "continuous": true, "executor": "@angular/build:dev-server", "options": { - "port": 4300 + "port": 4300, + "proxyConfig": "apps/portal-admin/proxy.conf.js" }, "configurations": { "production": { diff --git a/apps/portal-admin/proxy.conf.js b/apps/portal-admin/proxy.conf.js new file mode 100644 index 0000000..220f7a6 --- /dev/null +++ b/apps/portal-admin/proxy.conf.js @@ -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, + }, +}; diff --git a/apps/portal-admin/src/environments/environment.ts b/apps/portal-admin/src/environments/environment.ts index 904d22d..74029f3 100644 --- a/apps/portal-admin/src/environments/environment.ts +++ b/apps/portal-admin/src/environments/environment.ts @@ -13,13 +13,17 @@ */ export const environment = { /** - * Origin + prefix of the BFF HTTP API. Same value as portal-shell - * — both SPAs talk to the same BFF (per ADR-0020 §"Where does - * the admin app live"). The admin-specific routing happens via - * the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in + * Prefix of the BFF HTTP API. Same value as portal-shell — both + * SPAs talk to the same BFF (per ADR-0020 §"Where does the admin + * app live"). The admin-specific routing happens via the + * `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in * `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` diff --git a/apps/portal-shell/project.json b/apps/portal-shell/project.json index d368a58..a719e41 100644 --- a/apps/portal-shell/project.json +++ b/apps/portal-shell/project.json @@ -83,6 +83,9 @@ "serve": { "continuous": true, "executor": "@angular/build:dev-server", + "options": { + "proxyConfig": "apps/portal-shell/proxy.conf.js" + }, "configurations": { "production": { "buildTarget": "portal-shell:build:production" diff --git a/apps/portal-shell/proxy.conf.js b/apps/portal-shell/proxy.conf.js new file mode 100644 index 0000000..091e65e --- /dev/null +++ b/apps/portal-shell/proxy.conf.js @@ -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:// +// :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, + }, +}; diff --git a/apps/portal-shell/src/environments/environment.ts b/apps/portal-shell/src/environments/environment.ts index 20234c6..e5d1d44 100644 --- a/apps/portal-shell/src/environments/environment.ts +++ b/apps/portal-shell/src/environments/environment.ts @@ -18,12 +18,24 @@ */ export const environment = { /** - * Origin + prefix of the BFF HTTP API. The SPA prepends this to - * every backend call (`${bffApiBaseUrl}/health`, etc.) and derives - * the OTel trace-header propagation pattern from its origin (see - * `observability/tracing.ts`). + * Prefix of the BFF HTTP API. The SPA prepends this to every + * backend call (`${bffApiBaseUrl}/health`, etc.) and derives the + * OTel trace-header propagation pattern from its resolved origin + * (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 diff --git a/apps/portal-shell/src/observability/tracing.ts b/apps/portal-shell/src/observability/tracing.ts index 8eacebe..36e1a55 100644 --- a/apps/portal-shell/src/observability/tracing.ts +++ b/apps/portal-shell/src/observability/tracing.ts @@ -55,7 +55,13 @@ const SERVICE_VERSION = 'dev'; // so a deploy-time change to `bffApiBaseUrl` automatically propagates // `traceparent` to the right origin. RegExp special chars are escaped // 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 provider = new WebTracerProvider({ diff --git a/infra/local/dev.compose.yml b/infra/local/dev.compose.yml index 37d53c6..dcb4cde 100644 --- a/infra/local/dev.compose.yml +++ b/infra/local/dev.compose.yml @@ -261,6 +261,11 @@ services: 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' @@ -271,6 +276,9 @@ services: 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'