# Architecture diagrams

Visual cross-cuts of `apf_portal`'s architecture. Each diagram summarises decisions that are spread across several ADRs and exists to help a contributor (or auditor, RSSI, IT contact) build a mental model fast. Decisions themselves live in [decisions/](decisions/); diagrams reflect the current accepted state.

Diagrams are written in [Mermaid](https://mermaid.js.org/) — text in markdown, rendered natively by Gitea, GitHub, and most IDE markdown viewers. Updating a diagram is an ordinary text edit reviewable in PR.

When a diagram is the _content_ of a single decision (e.g. a sequence diagram that captures a flow described by one ADR), it lives **inside that ADR**, not here. This file holds the _cross-cutting_ views.

---

## 1. System context (C4 level 1)

The portal as a black box, with the actors that interact with it and the external systems it depends on.

Sources: [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) (downstream APIs), [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) (audit access).

```mermaid
flowchart TB
  workforce([Workforce user<br/>employee])
  customer([Customer user<br/>external<br/>future])
  it([IT / Identity admin])
  rssi([RSSI / SOC analyst])

  portal[apf_portal<br/>web portal centralising<br/>access to existing apps]

  entra[(Microsoft Entra ID<br/>workforce tenant<br/>+ M365 Developer dev tenant)]
  extid[(Microsoft Entra External ID<br/>customer tenant<br/>future)]
  downstream[(Downstream APIs<br/>existing applications<br/>integrated by the portal)]

  workforce -->|browser HTTPS| portal
  customer -.->|browser HTTPS<br/>future| portal
  portal -->|OIDC Auth Code + PKCE| entra
  portal -.->|OIDC Auth Code + PKCE<br/>future| extid
  portal -->|OBO or signed assertion| downstream
  it -->|configures<br/>tenant + Conditional Access<br/>+ B2B invitations| entra
  rssi -->|reads audit events<br/>via audit_reader role| portal

  classDef future stroke-dasharray: 5 5,opacity:0.7
  class customer,extid future
```

Dashed = future scope (not v1). The customer audience is _designed for_ but not implemented (per the dual-audience design in ADR-0008); it is shown to make the future expansion legible.

---

## 2. Containers (C4 level 2)

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), [ADR-0020](decisions/0020-portal-admin-app.md) (admin SPA).

```mermaid
flowchart TB
  user([Workforce user])
  admin([Admin user<br/>role:admin])

  subgraph Browser["Browser"]
    spa["portal-shell<br/>Angular 21 SPA<br/>zoneless / Signals / Tailwind 4"]
    spaAdmin["portal-admin<br/>Angular 21 SPA<br/>distinct origin / bundle"]
  end

  subgraph OnPrem["On-prem deployment"]
    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")]
    redis[("Redis<br/>sessions + OBO token cache<br/>AES-256-GCM at rest")]
    otel["OTel Collector<br/>local sidecar<br/>(OTLP → backend, future)"]
  end

  entra[(Microsoft Entra ID)]
  downstream[(Downstream APIs)]

  user -->|HTTPS| spa
  admin -->|HTTPS| spaAdmin
  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
  spaAdmin -. "OTLP / HTTP<br/>(browser spans)" .-> otel
  bff -->|"OIDC Auth Code + PKCE<br/>via @azure/msal-node"| entra
  bff -->|Prisma 7| pg
  bff -->|"ioredis<br/>(opaque session id<br/>→ encrypted blob)"| redis
  bff -->|"OBO token (Entra-protected)<br/>or signed X-User-Assertion JWT<br/>+ traceparent"| downstream
  bff -. "OTLP / HTTP<br/>(server spans + Pino logs)" .-> otel
```

Notes embedded in the diagram:

- **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 `Portal.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.

---

## 3. Nx module boundaries

Which projects are allowed to depend on which. Encoded in `eslint.config.mjs` via `@nx/enforce-module-boundaries` with the `depConstraints` from [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md).

Each project carries two tags:

- **`scope:`** — `portal-shell`, `portal-bff`, or `shared` (for libs consumable by both apps).
- **`type:`** — `app`, `feature`, or `shared`.

A dependency is allowed only if both axes permit it.

```mermaid
flowchart LR
  subgraph Apps["apps · type:app"]
    shell["portal-shell<br/>scope:portal-shell"]
    admin["portal-admin<br/>scope:portal-admin"]
    bff["portal-bff<br/>scope:portal-bff"]
  end

  subgraph FeatureLibs["libs · type:feature"]
    fauth["feature-auth<br/>scope:portal-shell"]
  end

  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)"]
    sutil["shared-util<br/>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

  fauth --> sui
  fauth --> stokens
  fauth --> sutil

  sui --> stokens

  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` / `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.

---

## 4. CI/CD pipeline

How a change moves from a developer's keyboard to `main`. Reflects [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md) (local hooks), [ADR-0015](decisions/0015-cicd-gitea-actions.md) (Gitea Actions, trunk-based + squash-merge), [ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y gate), [ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md) (perf gate).

```mermaid
flowchart TB
  edit[Edit on feat/*, fix/*, chore/*, docs/* branch]
  pre[".husky/pre-commit<br/>lint-staged → prettier on staged"]
  msg[".husky/commit-msg<br/>commitlint → Conventional Commits"]
  push[git push origin feat/*]
  pr[Open PR → main]

  subgraph PRJobs["CI on PR · .gitea/workflows/ci.yml · all blocking"]
    direction LR
    j1["check<br/>nx affected -t<br/>format / lint / test / build"]
    j2["scan<br/>pnpm audit + Trivy + gitleaks"]
    j3["commits<br/>commitlint on PR range"]
    j4["perf<br/>Lighthouse CI<br/>CWV thresholds (ADR-0017)"]
    j5["a11y<br/>axe-core via Playwright<br/>(placeholder until first screens)"]
  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"}
  squash["Squash-merge<br/>subject = Conventional Commits<br/>(becomes the commit on main)"]
  tag[Tag vX.Y.Z]
  release[".gitea/workflows/release.yml<br/>(stub today, populated with on-prem deploy ADR)"]

  edit --> pre --> msg --> push --> pr
  pr --> PRJobs --> protection -->|all green| squash --> tag --> release

  cron["Weekly cron · Mon 04:00 UTC"]
  sched[".gitea/workflows/security-scheduled.yml<br/>full-tree Trivy + gitleaks<br/>+ Lighthouse on prod (when LHCI_PROD_URL set)"]
  cron --> sched
```

Note the parallelism: the five PR jobs run **in parallel**. The diagram shows the gate as one square because branch protection requires _all five_ before squash-merge is allowed.

---

## 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

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                                                                                           | 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                           |
