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
This commit was merged in pull request #259.
This commit is contained in:
2026-06-01 11:39:40 +02:00
parent c080d1ad89
commit a84ea2d116
8 changed files with 99 additions and 12 deletions
@@ -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`