## 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
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.yamlunderallowBuilds(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(thevitest-angularrunner). Angular Testing Library is added for component tests when the first real components land (S-08c); the S-08a placeholder uses a plainTestBedrender assertion. - Lint: ESLint (flat config,
@nx/eslint). - Nx is scoped to
apps/+libs/only. The@nx/dockerand@nx/dotnetplugins are not installed — the .NET services are built bydotnet/the Makefile, and@nx/dockerwould otherwise infer everyservices/*/Dockerfileas an unnamed Nx project and break the project graph. - No Nx Cloud.
nxCloudIdis stripped fromnx.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
frontendjob (make frontend→pnpm 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, unlikeopenapi-generator), so it runs in the pnpm/Node CI lane. It emits an injectableBffApiV1Serviceusing Angular'sHttpClient— which means the DigiD bearer token can be attached by anHttpInterceptor(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 intosrc/lib/generated/,clean: true, prettier). Regenerate withnx run api-client:generateafter the BFF spec changes; the output is deterministic (idempotent), andsrc/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 inapps/self-service/src/styles.css). Utrecht is NL DS's reference Angular implementation. Its v3 components are NgModule-based, not standalone, solibs/uire-exportsUtrechtComponentsModule(and the component classes, so the AOT compiler resolves the template directives through the barrel); standalone components consume it viaimports: [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 Keycloakdigidrealm (public clientbig-portal). A smallAuthServiceabstraction (bsn / isAuthenticated / login) wraps the library so components and theauthenticatedGuarddepend on a mockable surface; a tokenHttpInterceptorattaches the bearer to BFF calls (secure route). The OIDCauthority/secureApiOriginare dev defaults inapp.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) withAuthServiceand the api-client mocked; the axe (vitest-axe) check runs scoped to WCAG 2.1 AA tags (wcag2a/2aa/21a/21aa) with the documentlangset, 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-serviceimage serves the built app and reverse-proxies/self-service/*+/openbaar/*to thebffservice. 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 barebffname on every engine — compose, podman and Kubernetes (ADR-0034; theCaddyfilesits next to each app'sDockerfile). - Runtime config. The app fetches
/config.jsonbefore bootstrap (main.ts);appConfigis a factory. The dev default (public/config.json) points atlocalhost: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.shruns Playwright in a container oncg, so the browser reaches Keycloak askeycloak:8080— the same issuer the BFF validates against (resolves the browser-vs-container mismatch, ADR-0010). It uses the officialmcr.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 withtests/e2e/package.json's@playwright/testversion. The spec is copied in (docker cp), not mounted, so it leaves nothing root-owned on the host. Wired asverify-e2ein theverify-stackCI 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, soauthorize()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 fullchannel: 'chromium'build, not the default headless-shell). This emulates the production HTTPS secure context without touching the app or its production config. tests/e2eis a standalone Playwright project (its ownpackage.json), not an Nx project — it's a live-stack check like the otherverify-*runners, not part of thefrontendunit lane.
Openbaar Register portal (S-09, #10)
- Anonymous, no auth. The openbaar register is a public read, so
apps/openbaarhas noangular-auth-oidc-client, no interceptor, and noconfig.json—main.tsbootstrapsappConfigdirectly with justprovideHttpClient+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
openbaarimage serves the built app and reverse-proxies/openbaarto 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/naamnever leave the BFF. The e2e asserts the bsn never renders. - Loads on open, filters on search.
RegisterPagefetches 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 Keycloakmedewerkerrealm (public clientbig-portal), notdigid. Rather than fork the auth lib, the abstractAuthServicegrew aroles/hasRolesurface (empty for realms without roles, e.g.digid), and a parallelMedewerkerAuthService+provideMedewerkerAuthwere added — same auth-code + PKCE config, bound to the medewerker realm, reading the nestedrealm_access.rolesclaim. The library's ownauthInterceptorattaches 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-portalclient gets a realm-roles protocol mapper (realm_access.roles, added to id + userinfo tokens). The BFF remains the security boundary (behandelaarpolicy, 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
behandelimage serves the built app and reverse-proxies/behandelto 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.
WerkbakPageloadsGET /behandel/werkbakon open and renders a row per registration (referentie/bsn/status). Goedkeuren/afwijzenPOST /behandel/registrations/ {id}/decideand then reload the werkbak, so the handled item drops off (its FlowableBeoordelentask is completed). Per-row decide buttons carry anaria-labelincluding the reference, so the e2e (and screen readers) can target a specific registration in a shared werkbak. - Testing. Component tests use
@testing-library/angularwithBffApiV1Service/AuthServicemocked and the axe WCAG 2.1 AA check; anapp.config.specdrives 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 istests/e2e/withdrawal.spec.ts.