From 4af0269edf57f9cc6431645d3b9af233e5aae281 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Wed, 13 May 2026 11:11:41 +0200 Subject: [PATCH] docs(architecture): refresh containers + nx boundaries diagrams, add local-infra diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post-auth-track état-des-lieux of docs/architecture.md. three small lies caught, one diagram added. containers (§2): portal-admin (adr-0020) was missing from the c4 level-2 view despite being scaffolded under apps/portal-admin and shipping its own session cookie. added as a sibling spa on a distinct origin with its own session (__host-portal_admin_session), and the bff box now distinguishes /api/* (end-user) from /api/admin/* (rbac + @requiremfa). nx boundaries (§3): - shared-ui was drawn under scope:portal-shell. its actual tag in project.json is scope:shared. same for shared-state, which wasn't on the diagram at all. both moved to the scope:shared bubble. - portal-admin absent. added with planned (dashed) edges to the shared libs; no scope:portal-admin row exists yet in eslint.config.mjs, that's a follow-up when admin modules grow. - the "forbidden" examples framed shared-tokens ⟶ shared-ui as a narrower-scope violation. both are scope:shared / type:shared, so depConstraints actually permit it. replaced with examples that are genuinely lint-enforced. local dev infrastructure (§5, new): visualises what `docker compose up` starts: postgres + redis + otel-collector always-up, plus opt-in profiles dbtools (pgweb), observability (jaeger), serve-static (caddy per adr-0019). shows ports, named volumes, bring-up cheat sheet, and the separate ci-runners stack (apf-portal-ci-runners, dedicated network). source links point at the compose files so a contributor chases details in one click. to-be-added table: trace-context-propagation row updated — its trigger ("first observability instrumentation lands") was already met. now flagged as overdue / pending a follow-up. also carries the one-line mermaid syntax fix on §4's `squash` node (parens-inside-label needed quoting). pre-existing local edit. --- docs/architecture.md | 151 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 21 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5cf3ecd..e18a7ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -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 flowchart TB user([Workforce user]) + admin([Admin user
role:admin]) subgraph Browser["Browser"] spa["portal-shell
Angular 21 SPA
zoneless / Signals / Tailwind 4"] + spaAdmin["portal-admin
Angular 21 SPA
distinct origin / bundle"] end subgraph OnPrem["On-prem deployment"] - bff["portal-bff
NestJS 11 / Node 24
Express adapter"] + bff["portal-bff
NestJS 11 / Node 24
Express adapter
· /api/* (end-user)
· /api/admin/* (RBAC + @RequireMfa)"] pg[("Postgres 17
schemas: public + audit
RLS for dual-audience")] redis[("Redis
sessions + OBO token cache
AES-256-GCM at rest")] otel["OTel Collector
local sidecar
(OTLP → backend, future)"] @@ -68,8 +70,11 @@ flowchart TB downstream[(Downstream APIs)] user -->|HTTPS| spa + admin -->|HTTPS| spaAdmin spa -->|"HTTPS
__Host-portal_session
X-CSRF-Token
traceparent"| bff + spaAdmin -->|"HTTPS
distinct session cookie
(separate sign-in flow,
fresh-MFA enforced)
traceparent"| bff spa -. "OTLP / HTTP
(browser spans)" .-> otel + spaAdmin -. "OTLP / HTTP
(browser spans)" .-> otel bff -->|"OIDC Auth Code + PKCE
via @azure/msal-node"| entra bff -->|Prisma 7| pg bff -->|"ioredis
(opaque session id
→ encrypted blob)"| redis @@ -79,7 +84,8 @@ flowchart TB 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. - 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 subgraph Apps["apps · type:app"] shell["portal-shell
scope:portal-shell"] + admin["portal-admin
scope:portal-admin"] bff["portal-bff
scope:portal-bff"] end @@ -107,20 +114,24 @@ flowchart LR fauth["feature-auth
scope:portal-shell"] end - subgraph SharedShellLibs["libs · type:shared · scope:portal-shell"] - sui["shared-ui
Angular components
(spartan-style on CDK)"] - end - subgraph SharedAnyLibs["libs · type:shared · scope:shared"] + sui["shared-ui
Angular components
(spartan-style on CDK)"] + sstate["shared-state
cross-surface signals
(LayoutStateService, ...)"] stokens["shared-tokens
design tokens (a11y)"] sutil["shared-util
cross-runtime TS helpers"] end shell --> fauth shell --> sui + shell --> sstate shell --> stokens shell --> sutil + admin -. "future" .-> sui + admin -. "future" .-> sstate + admin -. "future" .-> stokens + admin -. "future" .-> sutil + bff --> stokens bff --> sutil @@ -133,11 +144,16 @@ flowchart LR 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: -- `portal-bff` ⟶ `shared-ui` (back imports Angular code: scope clash). -- `portal-bff` ⟶ `feature-auth` (back imports a frontend feature). -- `shared-tokens` ⟶ `shared-ui` (a `type:shared` lib cannot reach a `type:shared` lib in a narrower scope, nor a feature lib). +- `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-shell` ⟶ `portal-admin` (cross-app — apps don't reach into each other; they communicate via the BFF). +- `shared-tokens` ⟶ `feature-auth` (`type:shared` may only reach `type:shared`, so feature libs are off-limits). - Any lib ⟶ any app. --- @@ -164,7 +180,7 @@ flowchart TB end protection{"Branch protection on main
· all 5 jobs green
· ≥0 reviewers (v1, →≥1 with 2nd contributor)
· linear history
· no direct push, no force push"} - squash[Squash-merge
subject = Conventional Commits
(becomes the commit on main)] + squash["Squash-merge
subject = Conventional Commits
(becomes the commit on main)"] tag[Tag vX.Y.Z] release[".gitea/workflows/release.yml
(stub today, populated with on-prem deploy ADR)"] @@ -180,17 +196,110 @@ 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
image: postgres:17.2-alpine
host port 5432
POSTGRES_USER / _PASSWORD / _DB
bootstrap SQL from init/postgres/"] + redis["redis
image: redis:7.4-alpine
host port 6379
--requirepass + --appendonly yes"] + otel["otel-collector
image: otel/opentelemetry-collector-contrib:0.150.1
OTLP gRPC 4317 / HTTP 4318
config: otel-collector.yaml"] + end + + subgraph ProfileDbtools["profile: dbtools"] + direction LR + pgweb["pgweb
image: sosedoff/pgweb:0.16.2
host port 8081"] + end + + subgraph ProfileObs["profile: observability"] + direction LR + jaeger["jaeger
image: jaegertracing/jaeger:2.17.0
UI host port 16686
OTLP receiver internal only"] + end + + subgraph ProfileServeStatic["profile: serve-static"] + direction LR + caddy["serve-static (Caddy)
image: caddy:2.10-alpine
host port 4200
serves dist/apps/portal-shell/browser
(per-locale routing per ADR-0019)"] + end + + vols[("Named volumes
apf-portal-postgres-data
apf-portal-redis-data")] + end + + subgraph CIStack["docker compose · name: apf-portal-ci-runners · network: act-runners"] + direction LR + r1["runner-1
gitea/act_runner:0.2.13
labels: self-hosted, on-prem
image: catthehacker/ubuntu:act-22.04"] + r2["runner-2
(same image / config)"] + r3["runner-3
(same image / config)"] + end + + bffProc["portal-bff
local Node process
(pnpm nx serve portal-bff)
port 3000"] + spaProc["portal-shell
Angular dev server
(pnpm nx serve portal-shell)
port 4200"] + end + + pg -. "AOF / data" .-> vols + redis -. "AOF / data" .-> vols + pgweb --> pg + otel -. "forward" .-> jaeger + caddy -. "reads
dist bind-mount" .-> caddy + + bffProc -->|DATABASE_URL| pg + bffProc -->|REDIS_URL| redis + bffProc -. "OTLP HTTP" .-> otel + spaProc -. "OTLP HTTP
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 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 | -| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| **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 | -| **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 | -| **Database ERD** | inline in [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) or a dedicated `data.md` | first business model in `schema.prisma` | -| **Audit event lifecycle** (writer → store → archiver → reader) | inline in [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) | audit module shipped | -| **Downstream call lifecycle** (audience pre-check → strategy → cache → resilience → trace) | inline in [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) | first downstream integration | -| **Trust boundaries** (security review view) | here | when the RSSI requests a security architecture review | +| 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 | +| **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 | +| **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` | +| **Audit event lifecycle** (writer → store → archiver → reader) | inline in [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) | audit module shipped | +| **Downstream call lifecycle** (audience pre-check → strategy → cache → resilience → trace) | inline in [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) | first downstream integration | +| **Trust boundaries** (security review view) | here | when the RSSI requests a security architecture review | -- 2.30.2