Files
register-referentie/docs/frontend-decisions.md
T
not 1dd8bd4e1b
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m12s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m58s
CI / mutation (push) Successful in 9m1s
CI / verify-stack (push) Successful in 8m53s
S-24/#25 · Helm chart + Kubernetes deployment, and Caddy for the portals (#166) (#167)
## What & why

Two changes, made and verified together on a real cluster.

**S-24 / #25 — a Helm chart for the platform.** One chart, `infra/helm/big-reference`,
whose `values.yaml` is a near-literal transcription of `infra/docker-compose.yml`, rendered
by three generic templates (Deployment, Job, Service) over a `workloads` map. Adding a
service is a values edit. `make k8s-lint` renders and schema-checks the whole stack without
a cluster. The issue asked for a *sketch*; this is deployed and verified end to end (see
below), which is more than it asked for — the part it asked for that is **not** here is the
production-posture write-up (HA, secrets, backup), see Known gaps.

**#166 — Caddy replaces nginx in the portals.** nginx resolves a variable `proxy_pass`
upstream itself, using only the `resolver` directive and never `/etc/resolv.conf`'s search
domains. That had cost two workarounds in one script: rewriting the resolver address for
rootless podman, and injecting a full FQDN so the bare `bff` name could resolve on
Kubernetes. Caddy dials per request through the system resolver, so `reverse_proxy
bff:8080` works on every engine unchanged; `apps/portal-nginx-resolver.sh` and the chart's
`BFF_HOST` env are deleted.

Closes #25
Closes #166

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation — twice: the Caddyfile contract test
      before the Caddyfiles, `make k8s-lint` before the chart.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issues (`refs #25` / `refs #166`).
- [ ] CI green — awaiting the run on this PR (`make k8s-lint`, `dotnet format` and the new
      unit self-check pass locally; the compose e2e and mutation lanes are CI's).
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes —
      the portal images were rebuilt and verified standalone, but a full `make up` run has
      not been done on this branch. Please confirm in review or let CI's smoke test speak.
- [x] Docs updated — `docs/runbooks/kubernetes-talos.md` (new), `frontend-decisions.md`,
      `demo-script.md`, and the docs that named nginx.
- [x] ADR added — ADR-0033 (chart) and ADR-0034 (Caddy).
- [ ] Demo note in `docs/demo-script.md` — not added: the deployment target is not a
      user-visible slice, and the Caddy swap is invisible to the demo script beyond the
      wording fix included here.

## How it was verified

Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk)
under virt-manager: **29 pods ready and four bootstrap Jobs complete in under three
minutes, zero restarts**, using ~4.4 GB of the VM's 10 GB.

- Full Common Ground path: portal Caddy → BFF → domain → Flowable → ACL → OpenZaak +
  Objecten → NRC → event-subscriber → projection → public register (`INGEDIEND`, reference
  matching the submitted registration).
- Werkbak read with an MFA'd medewerker token → 200.
- The browser flow driven with Playwright against `http://localhost:30140`: secure context,
  `crypto.subtle` present, Keycloak form reached, login completed, **no console errors**.
- Routing checked against a stub BFF: SPA fallback serves deep links, each portal proxies
  its own groups, and a portal does *not* proxy a neighbour's group.

## Notes for reviewers

Three bugs this shook out, each fixed at the cause rather than the symptom:

1. **`command` vs `args`.** Compose's `command:` replaces the image CMD; Kubernetes'
   replaces the ENTRYPOINT. Transcribing one to the other broke every upstream image that
   relies on its entrypoint — postgres refused to run as root, Keycloak tried to exec
   `start-dev`. The chart now `fail`s at render time on `command`.
2. **Concurrent migrations.** Both `/setup_configuration.sh` and `/start.sh` run
   `manage.py migrate`; compose serialises them with `depends_on`, Kubernetes has no such
   edge, so the init Job and its web pod raced (`relation "zgw_consumers_service" already
   exists`). The four Django services now do both steps in order in the web pod — which
   also deletes four workloads.
3. **`emptyDir` databases are wiped by any pod-template change.** `make k8s-reseed` now
   also restarts `event-subscriber` and `projection-api`, which create the projection
   schema on start and otherwise keep writing to a schema-less database.

Known gaps / follow-ups:

- **Secrets.** `values.yaml` carries the dev credentials in plain text (`admin/admin`, the
  ZGW client secret, the two Objecten tokens) and the chart has no `Secret` objects. Fine
  for a laptop demo, and exactly what #25's "production posture" ADR should address — I
  suggest a follow-up issue rather than stretching this PR.
- **No CI gate for the chart yet.** `make k8s-lint` exists but is not wired into
  `.gitea/workflows/ci.yaml`, and nothing enforces that the chart and the compose file stay
  in step. Worth a small follow-up.
- **This is two slices in one PR.** They were built and verified together and the diff is
  entangled (the chart was written against Caddy from the start), so splitting now would
  mean re-creating an nginx-shaped chart to throw away. Happy to split if you'd rather.
- **Rebased onto #161** (merged as #165) rather than merged, to keep the history linear.
  One conflict, in the `unit:` target where both branches add a self-check line — resolved
  by keeping both. #161's `infra/host-browser.yml` arrived with
  `/usr/share/nginx/html/config.json` and is fixed to `/usr/share/caddy/` inside the
  `feat(portals)` commit, so no commit on this branch leaves that overlay pointing at a
  path the images no longer have.Reviewed-on: #167
2026-09-10 08:53:58 +00:00

12 KiB

Frontend decisions

A running log of frontend tooling and component decisions (CLAUDE.md §10). One entry per decision; record why, and note any deviation from NL Design System.


Workspace & tooling (S-08a, #65)

The portals live in an Nx monorepo at the repository root, alongside the .NET services/.

  • Package manager: pnpm. Native build scripts are approved explicitly in pnpm-workspace.yaml under allowBuilds (pnpm 11 fails the install otherwise). Node 24, pnpm 11.
  • Angular, standalone components + signals, no NgModules (§10). Apps are generated with @nx/angular:application.
  • Unit tests: Vitest via Angular's built-in @angular/build:unit-test (the vitest-angular runner). Angular Testing Library is added for component tests when the first real components land (S-08c); the S-08a placeholder uses a plain TestBed render assertion.
  • Lint: ESLint (flat config, @nx/eslint).
  • Nx is scoped to apps/ + libs/ only. The @nx/docker and @nx/dotnet plugins are not installed — the .NET services are built by dotnet/the Makefile, and @nx/docker would otherwise infer every services/*/Dockerfile as an unnamed Nx project and break the project graph.
  • No Nx Cloud. nxCloudId is stripped from nx.json; remote caching would depend on an external service, and the repo is Gitea-only (§8.7). Nx's "configure-ai-agents" additions (.claude/settings.json, a CLAUDE.md section referencing a GitHub marketplace) are not committed for the same reason.
  • CI: a frontend job (make frontendpnpm install --frozen-lockfile + nx run-many -t lint test build) runs on pnpm + Node, with pinned action URLs (§15).

NL Design System: not yet introduced — the S-08a app is a placeholder. NL DS components arrive with the submit form (S-08c, #67); any deviation from NL DS will be recorded here.


API client generator (S-08b, #66)

libs/api-client is generated from services/bff/openapi.json — never hand-written (§10).

  • Generator: orval (client: 'angular'), a node-based generator (no Java, unlike openapi-generator), so it runs in the pnpm/Node CI lane. It emits an injectable BffApiV1Service using Angular's HttpClient — which means the DigiD bearer token can be attached by an HttpInterceptor (S-08c), the idiomatic Angular approach; a fetch-based SDK would bypass the interceptor pipeline.
  • Config: libs/api-client/orval.config.ts (single-file output into src/lib/generated/, clean: true, prettier). Regenerate with nx run api-client:generate after the BFF spec changes; the output is deterministic (idempotent), and src/lib/generated/ is never hand-edited.
  • Tested against a mocked BFF via HttpClientTesting (libs/api-client/src/lib/bff-api.spec.ts).
  • The BFF endpoints carry no operationId, so orval synthesises method names (postSelfServiceRegistrations, getOpenbaarRegister); adding explicit operation ids to the BFF is a possible later polish.

Self-service form: NL DS, DigiD auth, testing (S-08c, #67)

  • NL Design System via @utrecht/component-library-angular (libs/ui) + @utrecht/design-tokens (imported once in apps/self-service/src/styles.css). Utrecht is NL DS's reference Angular implementation. Its v3 components are NgModule-based, not standalone, so libs/ui re-exports UtrechtComponentsModule (and the component classes, so the AOT compiler resolves the template directives through the barrel); standalone components consume it via imports: [UtrechtComponentsModule]. §10's "no NgModules in new code" governs our code — consuming a third-party module is fine.
  • DigiD login via angular-auth-oidc-client (libs/auth): auth-code + PKCE against the Keycloak digid realm (public client big-portal). A small AuthService abstraction (bsn / isAuthenticated / login) wraps the library so components and the authenticatedGuard depend on a mockable surface; a token HttpInterceptor attaches the bearer to BFF calls (secure route). The OIDC authority/secureApiOrigin are dev defaults in app.config.ts; the compose-served app overrides them (S-08d), and the browser-vs-container issuer alignment is handled there (ADR-0010).
  • Testing: component tests use @testing-library/angular (§10) with AuthService and the api-client mocked; the axe (vitest-axe) check runs scoped to WCAG 2.1 AA tags (wcag2a/2aa/21a/21aa) with the document lang set, asserting zero violations on the submit page. The real DigiD browser round-trip is exercised in S-08d (Playwright).
  • Module boundaries: replaced the demo eslint depConstraints (scope:shop/scope:shared, left over from the Nx angular template) with a permissive * default; scope/type tags can be introduced when the portal set grows.

Serving + e2e (S-08d, #68)

  • Served by Caddy, same-origin as the BFF. The compose self-service image serves the built app and reverse-proxies /self-service/* + /openbaar/* to the bff service. Because the api-client uses relative URLs, the browser calls the app's own origin → Caddy forwards to the BFF: no CORS, and the DigiD token (same-origin) is attached by the interceptor. Caddy dials the BFF per request through the system resolver, so it starts before the BFF is up, picks up its restarts, and resolves the bare bff name on every engine — compose, podman and Kubernetes (ADR-0034; the Caddyfile sits next to each app's Dockerfile).
  • Runtime config. The app fetches /config.json before bootstrap (main.ts); appConfig is a factory. The dev default (public/config.json) points at localhost:8180; the Docker image bakes the compose value (keycloak:8080). One build, per-environment OIDC authority.
  • e2e runs inside the compose network. infra/run-e2e-check.sh runs Playwright in a container on cg, so the browser reaches Keycloak as keycloak:8080 — the same issuer the BFF validates against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official mcr.microsoft.com/playwright:<version> image with browsers pre-baked, rather than downloading ~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with tests/e2e/package.json's @playwright/test version. The spec is copied in (docker cp), not mounted, so it leaves nothing root-owned on the host. Wired as verify-e2e in the verify-stack CI job.
  • e2e treats the portal origin as secure. In-network the portal is served over plain HTTP on a non-localhost origin (http://self-service), which is not a secure context, so Web Crypto (crypto.subtle) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so authorize() throws and the login redirect never fires. Production runs behind HTTPS where this is a non-issue; rather than terminate TLS in the throwaway stack, the Playwright config passes --unsafely-treat-insecure-origin-as-secure (honoured only by the full channel: 'chromium' build, not the default headless-shell). This emulates the production HTTPS secure context without touching the app or its production config.
  • tests/e2e is a standalone Playwright project (its own package.json), not an Nx project — it's a live-stack check like the other verify-* runners, not part of the frontend unit lane.

Openbaar Register portal (S-09, #10)

  • Anonymous, no auth. The openbaar register is a public read, so apps/openbaar has no angular-auth-oidc-client, no interceptor, and no config.jsonmain.ts bootstraps appConfig directly with just provideHttpClient + provideRouter. This is the deliberate contrast to self-service and keeps the app trivially cacheable/CDN-able.
  • Same-origin via Caddy, like self-service. The compose openbaar image serves the built app and reverse-proxies /openbaar to the BFF; the api-client's relative calls stay same-origin (no CORS). Served on :8141, health-checked over IPv4 (127.0.0.1), no Keycloak dependency.
  • Public-safe by construction. The portal only ever sees the BFF's OpenbaarProjection.PublicView (id + status); bsn/naam never leave the BFF. The e2e asserts the bsn never renders.
  • Loads on open, filters on search. RegisterPage fetches the full register on construction and re-queries /openbaar/register?q= on search — no client-side filtering, the BFF owns the query.

Behandel portal (S-12, #13)

The staff portal where a behandelaar works the werkbak (registrations awaiting beoordeling) and decides each — goedkeuren or afwijzen. apps/behandel mirrors apps/self-service; the net-new frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in ADR-0013; this entry records the frontend-specific choices.

  • Medewerker realm auth, reusing libs/auth. Staff authenticate against the Keycloak medewerker realm (public client big-portal), not digid. Rather than fork the auth lib, the abstract AuthService grew a roles/hasRole surface (empty for realms without roles, e.g. digid), and a parallel MedewerkerAuthService + provideMedewerkerAuth were added — same auth-code + PKCE config, bound to the medewerker realm, reading the nested realm_access.roles claim. The library's own authInterceptor attaches the token to the relative /behandel/ calls (secure route), exactly as self-service does for /self-service/.
  • Roles reach the frontend via a realm mapper. Keycloak emits realm roles in the access token by default but not the ID token/userinfo the SPA reads, so the medewerker big-portal client gets a realm-roles protocol mapper (realm_access.roles, added to id + userinfo tokens). The BFF remains the security boundary (behandelaar policy, 401/403 on /behandel/*, ADR-0013); the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a 403 for a non-behandelaar) rather than swallowing it.
  • Same-origin via Caddy, like the other portals. The compose behandel image serves the built app and reverse-proxies /behandel to the BFF (relative calls, no CORS). Served on :8142, health-checked over IPv4 (127.0.0.1), depends on Keycloak for the medewerker realm.
  • Werkbak = decide-and-refresh. WerkbakPage loads GET /behandel/werkbak on open and renders a row per registration (referentie/bsn/status). Goedkeuren/afwijzen POST /behandel/registrations/ {id}/decide and then reload the werkbak, so the handled item drops off (its Flowable Beoordelen task is completed). Per-row decide buttons carry an aria-label including the reference, so the e2e (and screen readers) can target a specific registration in a shared werkbak.
  • Testing. Component tests use @testing-library/angular with BffApiV1Service/AuthService mocked and the axe WCAG 2.1 AA check; an app.config.spec drives the real interceptor + api-client to assert the medewerker token attaches to /behandel/* (and not to the anonymous openbaar call). The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy path.

Self-service withdrawal: "trek aanvraag in" (S-11c, #12)

The submit confirmation grows a "Trek aanvraag in" action so a zorgprofessional can withdraw the registration they just submitted (apps/self-service, on the existing RegistrationPage).

  • Keyed by the reference, owner-scoped at the BFF. The button calls the generated postSelfServiceRegistrationsIdWithdraw(reference) with the reference the submit returned. The DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
  • Same confirm-and-surface pattern as submit. A secondary-action button; on success the page switches to an ingetrokken confirmation; a failure is surfaced (role="alert") and the action stays available to retry — mirroring how submit handles its failure rather than swallowing it.
  • Testing. Component tests (@testing-library/angular, mocked BFF) cover the button appearing after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the browser round-trip is tests/e2e/withdrawal.spec.ts.