docs(architecture): refresh containers + nx boundaries diagrams, add local-infra diagram (#119)
CI / check (push) Successful in 2m8s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m14s
CI / a11y (push) Successful in 1m12s
CI / perf (push) Successful in 4m3s

## Summary

Dépoussiérage of `docs/architecture.md` after the auth/session track and a new local-infra diagram.

### Fixed

- **§2 Containers** — `portal-admin` was missing despite shipping in ADR-0020 and being scaffolded in `apps/portal-admin`. Added as a sibling SPA with its own session cookie (`__Host-portal_admin_session`), and the BFF box now distinguishes `/api/*` (end-user) from `/api/admin/*` (RBAC + `@RequireMfa({ freshness: 600 })`).
- **§3 Nx module boundaries** — three small lies:
  - `shared-ui` was drawn under `scope:portal-shell`. The actual tag in [libs/shared/ui/project.json:7](libs/shared/ui/project.json#L7) is `scope:shared`. Same for `shared-state` (which wasn't on the diagram at all). Both now live in the `scope:shared` bubble.
  - `portal-admin` was absent. Added with its planned (dashed) edges to the shared libs and a note that no `scope:portal-admin` row exists in `eslint.config.mjs` yet — that lands when admin modules grow real lib deps (follow-up).
  - The "forbidden" examples included `shared-tokens ⟶ shared-ui` framed as "narrower scope", which doesn't apply (both are `scope:shared` / `type:shared`). Replaced with examples actually enforced by `depConstraints`.

### Added

- **§5 Local dev infrastructure** — visualises what `docker compose up` actually starts: postgres / redis / otel-collector always up, plus opt-in profiles `dbtools` (pgweb), `observability` (Jaeger), `serve-static` (Caddy). Shows ports, named volumes, the bring-up cheat sheet, and the separate CI-runners stack (`apf-portal-ci-runners`, distinct network). Sources point straight at the compose files so a contributor can chase any detail in one click.

### Misc

- Updated the "Trace context propagation" row in the "To be added" table — its trigger ("first observability instrumentation lands") was already met. Now flagged as overdue / pending a follow-up PR (deferred from this scope).
- Carried the pre-existing one-line fix on the §4 `squash[…]` node (mermaid was rejecting unquoted parens inside the label).

## Test plan

- [x] `pnpm exec prettier --check docs/architecture.md` → clean.
- [x] Re-read all five mermaid blocks for syntax (no unquoted parens / `:` / `(` inside `[]` labels). Compose-side `--profile X` only appears inside `"…"` so it's safe.
- [ ] Render in Gitea / IDE markdown preview — the five mermaid blocks should display without errors.
- [ ] Eyeball §5 against [infra/local/dev.compose.yml](../infra/local/dev.compose.yml): every service + profile present, ports match.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #119
This commit was merged in pull request #119.
This commit is contained in:
2026-05-13 11:15:03 +02:00
parent 177f2f20c0
commit 6aee56abe9
+122 -13
View File
@@ -47,18 +47,20 @@ Dashed = future scope (not v1). The customer audience is _designed for_ but not
The runtime artefacts and their conversations. One step deeper than system context: what is actually deployed. The runtime artefacts and their conversations. One step deeper than system context: what is actually deployed.
Sources: [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md) (Nx layout), [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular SPA), [ADR-0005](decisions/0005-backend-stack-nestjs.md) (NestJS BFF), [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](decisions/0010-session-management-redis.md) (sessions in Redis), [ADR-0012](decisions/0012-observability-pino-opentelemetry.md) (OTel collector), [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) (downstream calls). Sources: [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md) (Nx layout), [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular SPA), [ADR-0005](decisions/0005-backend-stack-nestjs.md) (NestJS BFF), [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](decisions/0010-session-management-redis.md) (sessions in Redis), [ADR-0012](decisions/0012-observability-pino-opentelemetry.md) (OTel collector), [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) (downstream calls), [ADR-0020](decisions/0020-portal-admin-app.md) (admin SPA).
```mermaid ```mermaid
flowchart TB flowchart TB
user([Workforce user]) user([Workforce user])
admin([Admin user<br/>role:admin])
subgraph Browser["Browser"] subgraph Browser["Browser"]
spa["portal-shell<br/>Angular 21 SPA<br/>zoneless / Signals / Tailwind 4"] spa["portal-shell<br/>Angular 21 SPA<br/>zoneless / Signals / Tailwind 4"]
spaAdmin["portal-admin<br/>Angular 21 SPA<br/>distinct origin / bundle"]
end end
subgraph OnPrem["On-prem deployment"] subgraph OnPrem["On-prem deployment"]
bff["portal-bff<br/>NestJS 11 / Node 24<br/>Express adapter"] bff["portal-bff<br/>NestJS 11 / Node 24<br/>Express adapter<br/>· /api/* (end-user)<br/>· /api/admin/* (RBAC + @RequireMfa)"]
pg[("Postgres 17<br/>schemas: public + audit<br/>RLS for dual-audience")] pg[("Postgres 17<br/>schemas: public + audit<br/>RLS for dual-audience")]
redis[("Redis<br/>sessions + OBO token cache<br/>AES-256-GCM at rest")] redis[("Redis<br/>sessions + OBO token cache<br/>AES-256-GCM at rest")]
otel["OTel Collector<br/>local sidecar<br/>(OTLP → backend, future)"] otel["OTel Collector<br/>local sidecar<br/>(OTLP → backend, future)"]
@@ -68,8 +70,11 @@ flowchart TB
downstream[(Downstream APIs)] downstream[(Downstream APIs)]
user -->|HTTPS| spa user -->|HTTPS| spa
admin -->|HTTPS| spaAdmin
spa -->|"HTTPS<br/>__Host-portal_session<br/>X-CSRF-Token<br/>traceparent"| bff spa -->|"HTTPS<br/>__Host-portal_session<br/>X-CSRF-Token<br/>traceparent"| bff
spaAdmin -->|"HTTPS<br/>distinct session cookie<br/>(separate sign-in flow,<br/>fresh-MFA enforced)<br/>traceparent"| bff
spa -. "OTLP / HTTP<br/>(browser spans)" .-> otel spa -. "OTLP / HTTP<br/>(browser spans)" .-> otel
spaAdmin -. "OTLP / HTTP<br/>(browser spans)" .-> otel
bff -->|"OIDC Auth Code + PKCE<br/>via @azure/msal-node"| entra bff -->|"OIDC Auth Code + PKCE<br/>via @azure/msal-node"| entra
bff -->|Prisma 7| pg bff -->|Prisma 7| pg
bff -->|"ioredis<br/>(opaque session id<br/>→ encrypted blob)"| redis bff -->|"ioredis<br/>(opaque session id<br/>→ encrypted blob)"| redis
@@ -79,7 +84,8 @@ flowchart TB
Notes embedded in the diagram: Notes embedded in the diagram:
- The SPA carries no token. Only the opaque session id cookie (`__Host-portal_session`) plus the CSRF cookie (`__Host-portal_csrf`, read by JS for double-submit). - **Two SPAs, one BFF.** `portal-shell` is the end-user surface; `portal-admin` is a separate Angular app on a distinct origin with its own bundle, sign-in flow, and session cookie (per ADR-0020). They share libs but never a runtime — an admin session is not silently authenticated to `portal-shell` and vice versa. The admin entry route is gated by an Entra `admin` app-role claim + `@RequireMfa({ freshness: 600 })` per ADR-0011.
- The SPAs carry no token. Only the opaque session id cookie (`__Host-portal_session` / `__Host-portal_admin_session`) plus the CSRF cookie (`__Host-portal_csrf`, read by JS for double-submit).
- `traceparent` (W3C) propagates from the browser through the BFF to the downstream APIs — same `trace_id` end-to-end. - `traceparent` (W3C) propagates from the browser through the BFF to the downstream APIs — same `trace_id` end-to-end.
- The OTel Collector is the only piece coupled to the eventual on-prem observability backend. Choice of backend (Grafana Loki + Tempo, ELK, …) is deferred to the on-prem infrastructure ADR. - The OTel Collector is the only piece coupled to the eventual on-prem observability backend. Choice of backend (Grafana Loki + Tempo, ELK, …) is deferred to the on-prem infrastructure ADR.
@@ -100,6 +106,7 @@ A dependency is allowed only if both axes permit it.
flowchart LR flowchart LR
subgraph Apps["apps · type:app"] subgraph Apps["apps · type:app"]
shell["portal-shell<br/>scope:portal-shell"] shell["portal-shell<br/>scope:portal-shell"]
admin["portal-admin<br/>scope:portal-admin"]
bff["portal-bff<br/>scope:portal-bff"] bff["portal-bff<br/>scope:portal-bff"]
end end
@@ -107,20 +114,24 @@ flowchart LR
fauth["feature-auth<br/>scope:portal-shell"] fauth["feature-auth<br/>scope:portal-shell"]
end end
subgraph SharedShellLibs["libs · type:shared · scope:portal-shell"]
sui["shared-ui<br/>Angular components<br/>(spartan-style on CDK)"]
end
subgraph SharedAnyLibs["libs · type:shared · scope:shared"] subgraph SharedAnyLibs["libs · type:shared · scope:shared"]
sui["shared-ui<br/>Angular components<br/>(spartan-style on CDK)"]
sstate["shared-state<br/>cross-surface signals<br/>(LayoutStateService, ...)"]
stokens["shared-tokens<br/>design tokens (a11y)"] stokens["shared-tokens<br/>design tokens (a11y)"]
sutil["shared-util<br/>cross-runtime TS helpers"] sutil["shared-util<br/>cross-runtime TS helpers"]
end end
shell --> fauth shell --> fauth
shell --> sui shell --> sui
shell --> sstate
shell --> stokens shell --> stokens
shell --> sutil shell --> sutil
admin -. "future" .-> sui
admin -. "future" .-> sstate
admin -. "future" .-> stokens
admin -. "future" .-> sutil
bff --> stokens bff --> stokens
bff --> sutil bff --> sutil
@@ -133,11 +144,16 @@ flowchart LR
classDef forbidden stroke:#c00,stroke-dasharray: 4 4 classDef forbidden stroke:#c00,stroke-dasharray: 4 4
``` ```
Notes:
- `portal-admin` is a v1 skeleton (per ADR-0020) — it currently imports no libs. Its planned dependencies on the `scope:shared` set are drawn dashed; they materialise as the admin modules land. No `scope:portal-admin` row exists in `eslint.config.mjs` yet; one lands when the admin modules grow real lib dependencies (follow-up).
- Every Angular-flavoured shared lib (`shared-ui`, `shared-state`) lives under `scope:shared`, not `scope:portal-shell`. The naming was historically ambiguous; what matters is the tag in `project.json`, which gates lint-time enforcement.
Forbidden by the depConstraints (and lint-enforced) — examples: Forbidden by the depConstraints (and lint-enforced) — examples:
- `portal-bff``shared-ui` (back imports Angular code: scope clash). - `portal-bff``shared-ui` / `feature-auth` (`scope:portal-bff` may only reach `scope:portal-bff` + `scope:shared`; and the BFF runs in Node anyway, so Angular code is mechanically unusable).
- `portal-bff``feature-auth` (back imports a frontend feature). - `portal-shell``portal-admin` (cross-app — apps don't reach into each other; they communicate via the BFF).
- `shared-tokens``shared-ui` (a `type:shared` lib cannot reach a `type:shared` lib in a narrower scope, nor a feature lib). - `shared-tokens``feature-auth` (`type:shared` may only reach `type:shared`, so feature libs are off-limits).
- Any lib ⟶ any app. - Any lib ⟶ any app.
--- ---
@@ -164,7 +180,7 @@ flowchart TB
end end
protection{"Branch protection on main<br/>· all 5 jobs green<br/>· ≥0 reviewers (v1, →≥1 with 2nd contributor)<br/>· linear history<br/>· no direct push, no force push"} protection{"Branch protection on main<br/>· all 5 jobs green<br/>· ≥0 reviewers (v1, →≥1 with 2nd contributor)<br/>· linear history<br/>· no direct push, no force push"}
squash[Squash-merge<br/>subject = Conventional Commits<br/>(becomes the commit on main)] squash["Squash-merge<br/>subject = Conventional Commits<br/>(becomes the commit on main)"]
tag[Tag vX.Y.Z] tag[Tag vX.Y.Z]
release[".gitea/workflows/release.yml<br/>(stub today, populated with on-prem deploy ADR)"] release[".gitea/workflows/release.yml<br/>(stub today, populated with on-prem deploy ADR)"]
@@ -180,14 +196,107 @@ Note the parallelism: the five PR jobs run **in parallel**. The diagram shows th
--- ---
## 5. Local dev infrastructure
What `docker compose up` actually starts when you run the project locally. The aim is to make the runtime dependencies visible at a glance — services, ports, named volumes, optional profiles, and how they relate to the dev-loop tools (Jaeger, pgweb, Caddy).
Sources: [infra/local/dev.compose.yml](../infra/local/dev.compose.yml), [infra/ci-runners.compose.yml](../infra/ci-runners.compose.yml), [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) (Postgres), [ADR-0010](decisions/0010-session-management-redis.md) (Redis dev mode), [ADR-0012](decisions/0012-observability-pino-opentelemetry.md) (OTel Collector), [ADR-0015](decisions/0015-cicd-gitea-actions.md) (CI runners), [ADR-0019](decisions/0019-internationalisation-angular-localize.md) (Caddy locale routing).
```mermaid
flowchart TB
subgraph Host["Developer host"]
direction TB
subgraph DevStack["docker compose · name: apf-portal-dev · network: apf-portal-dev"]
direction TB
subgraph AlwaysUp["Always up"]
direction LR
pg["postgres<br/>image: postgres:17.2-alpine<br/>host port 5432<br/>POSTGRES_USER / _PASSWORD / _DB<br/>bootstrap SQL from init/postgres/"]
redis["redis<br/>image: redis:7.4-alpine<br/>host port 6379<br/>--requirepass + --appendonly yes"]
otel["otel-collector<br/>image: otel/opentelemetry-collector-contrib:0.150.1<br/>OTLP gRPC 4317 / HTTP 4318<br/>config: otel-collector.yaml"]
end
subgraph ProfileDbtools["profile: dbtools"]
direction LR
pgweb["pgweb<br/>image: sosedoff/pgweb:0.16.2<br/>host port 8081"]
end
subgraph ProfileObs["profile: observability"]
direction LR
jaeger["jaeger<br/>image: jaegertracing/jaeger:2.17.0<br/>UI host port 16686<br/>OTLP receiver internal only"]
end
subgraph ProfileServeStatic["profile: serve-static"]
direction LR
caddy["serve-static (Caddy)<br/>image: caddy:2.10-alpine<br/>host port 4200<br/>serves dist/apps/portal-shell/browser<br/>(per-locale routing per ADR-0019)"]
end
vols[("Named volumes<br/>apf-portal-postgres-data<br/>apf-portal-redis-data")]
end
subgraph CIStack["docker compose · name: apf-portal-ci-runners · network: act-runners"]
direction LR
r1["runner-1<br/>gitea/act_runner:0.2.13<br/>labels: self-hosted, on-prem<br/>image: catthehacker/ubuntu:act-22.04"]
r2["runner-2<br/>(same image / config)"]
r3["runner-3<br/>(same image / config)"]
end
bffProc["portal-bff<br/>local Node process<br/>(pnpm nx serve portal-bff)<br/>port 3000"]
spaProc["portal-shell<br/>Angular dev server<br/>(pnpm nx serve portal-shell)<br/>port 4200"]
end
pg -. "AOF / data" .-> vols
redis -. "AOF / data" .-> vols
pgweb --> pg
otel -. "forward" .-> jaeger
caddy -. "reads<br/>dist bind-mount" .-> caddy
bffProc -->|DATABASE_URL| pg
bffProc -->|REDIS_URL| redis
bffProc -. "OTLP HTTP" .-> otel
spaProc -. "OTLP HTTP<br/>browser spans" .-> otel
spaProc -->|"fetch /api/*"| bffProc
classDef profile fill:#f6f8fa,stroke:#bbb,stroke-dasharray: 3 3
class ProfileDbtools,ProfileObs,ProfileServeStatic profile
```
Bring-up cheat sheet:
```bash
# Always-up services only (postgres + redis + otel-collector).
docker compose -f infra/local/dev.compose.yml up -d
# With viewers (pgweb at :8081, Jaeger at :16686).
docker compose -f infra/local/dev.compose.yml \
--profile dbtools --profile observability up -d
# Plus the production-like static server (Caddy at :4200).
# Use after `pnpm exec nx build portal-shell --configuration=production`.
docker compose -f infra/local/dev.compose.yml --profile serve-static up -d
# Wipe state (recreates the bootstrap SQL on next up).
docker compose -f infra/local/dev.compose.yml down -v
```
Notes:
- **The BFF and SPA themselves are not in compose.** They run as local Node / Angular dev-server processes via `pnpm nx serve …`. Compose only hosts the runtime _dependencies_ — keeping the inner-loop edit/refresh fast. The Caddy `serve-static` profile is the production-shape exception: it lets you eyeball a prod build under the locale routing without standing up a real reverse proxy.
- **Named volumes are intentional.** Wiping `down -v` is the supported reset path; the Postgres bootstrap SQL (audit schema + role grants per ADR-0013) only runs on a fresh data volume.
- **The CI runners stack is separate** (`infra/ci-runners.compose.yml`, `name: apf-portal-ci-runners`) and lives on its own Docker network. Same host machine in v1, but co-located by convenience, not by coupling — they can move to a different host without touching the dev stack. The runners register with Gitea via `GITEA_RUNNER_REGISTRATION_TOKEN` on first boot and persist their credentials in `./data/runner-N/`.
- **Ports listed are the host-side defaults.** All can be overridden through `infra/local/.env` (`POSTGRES_PORT`, `REDIS_PORT`, `OTEL_HTTP_PORT`, `JAEGER_UI_PORT`, `SERVE_STATIC_PORT`, …). The container-side ports never change.
---
## To be added ## To be added
As features land, the following diagrams will be added — either here, or inline in the ADR they belong to (per the convention stated at the top: _cross-cutting → here, single-decision → in the ADR_). As features land, the following diagrams will be added — either here, or inline in the ADR they belong to (per the convention stated at the top: _cross-cutting → here, single-decision → in the ADR_).
| Diagram | Where it will land | Triggered by | | Diagram | Where it will land | Triggered by |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **OIDC Auth Code + PKCE sequence** | inline in [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) | already useful — added at the same time as this file | | **OIDC Auth Code + PKCE sequence** | inline in [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) | already useful — added at the same time as this file |
| **Trace context propagation** (SPA → BFF → DB → downstream) | here | first observability instrumentation lands | | **Trace context propagation** (SPA → BFF → DB → downstream) | here | trigger met — observability is wired end-to-end; diagram pending a follow-up PR |
| **Dual-audience flow** (token validation, claim → enum, RLS filtering) | here or split between ADRs 0008/0009/0013 | first authz code that touches the audience | | **Dual-audience flow** (token validation, claim → enum, RLS filtering) | here or split between ADRs 0008/0009/0013 | first authz code that touches the audience |
| **Step-up MFA flow** (claims challenge round-trip) | inline in [ADR-0011](decisions/0011-mfa-enforcement-entra-conditional-access.md) | first `@RequireMfa()` route | | **Step-up MFA flow** (claims challenge round-trip) | inline in [ADR-0011](decisions/0011-mfa-enforcement-entra-conditional-access.md) | first `@RequireMfa()` route |
| **Database ERD** | inline in [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) or a dedicated `data.md` | first business model in `schema.prisma` | | **Database ERD** | inline in [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) or a dedicated `data.md` | first business model in `schema.prisma` |