From a55ba1160dd02c5b71ed01195f9f51314e514f8e Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Wed, 1 Jul 2026 14:03:59 +0200
Subject: [PATCH 01/11] feat(portal-self-service): runtime config + nginx
serve/proxy image (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The app loads /config.json at startup (main.ts) so the OIDC authority is set per
environment from one build; appConfig becomes a factory and derives redirectUrl +
secureApiOrigin from the app origin (same-origin as the BFF). A multi-stage
Dockerfile builds the app and serves it via nginx, reverse-proxying /self-service
+ /openbaar to the bff (relative URLs → no CORS); nginx resolves the BFF at request
time. The compose image bakes config.json with the keycloak:8080 authority so the
browser's token issuer matches the BFF (ADR-0010).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/self-service/Dockerfile | 23 +++++++++++++++
apps/self-service/nginx.conf | 29 ++++++++++++++++++
apps/self-service/public/config.json | 3 ++
apps/self-service/src/app/app.config.ts | 39 ++++++++++++++++---------
apps/self-service/src/main.ts | 9 ++++--
5 files changed, 88 insertions(+), 15 deletions(-)
create mode 100644 apps/self-service/Dockerfile
create mode 100644 apps/self-service/nginx.conf
create mode 100644 apps/self-service/public/config.json
diff --git a/apps/self-service/Dockerfile b/apps/self-service/Dockerfile
new file mode 100644
index 0000000..6f72a08
--- /dev/null
+++ b/apps/self-service/Dockerfile
@@ -0,0 +1,23 @@
+# Multi-stage build for the self-service portal (Angular → nginx).
+# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
+FROM node:24-slim AS build
+WORKDIR /src
+RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
+
+# Restore first (cached unless the manifests change).
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
+RUN pnpm install --frozen-lockfile
+
+# Sources (only what the app + its libs need).
+COPY apps/self-service apps/self-service
+COPY libs libs
+RUN pnpm nx build self-service
+
+FROM nginx:1.27-alpine AS runtime
+COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
+# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
+# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
+RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
+
+EXPOSE 80
diff --git a/apps/self-service/nginx.conf b/apps/self-service/nginx.conf
new file mode 100644
index 0000000..d399eee
--- /dev/null
+++ b/apps/self-service/nginx.conf
@@ -0,0 +1,29 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
+ # even before the BFF is up and picks up restarts — instead of failing to load the config.
+ resolver 127.0.0.11 ipv6=off valid=30s;
+
+ # Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
+ # URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
+ # token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
+ location /self-service/ {
+ set $bff http://bff:8080;
+ proxy_pass $bff;
+ proxy_set_header Host $host;
+ }
+ location /openbaar/ {
+ set $bff http://bff:8080;
+ proxy_pass $bff;
+ proxy_set_header Host $host;
+ }
+
+ # SPA fallback — Angular client-side routing.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/apps/self-service/public/config.json b/apps/self-service/public/config.json
new file mode 100644
index 0000000..7642cac
--- /dev/null
+++ b/apps/self-service/public/config.json
@@ -0,0 +1,3 @@
+{
+ "authority": "http://localhost:8180/realms/digid"
+}
diff --git a/apps/self-service/src/app/app.config.ts b/apps/self-service/src/app/app.config.ts
index d7a7072..a4b7577 100644
--- a/apps/self-service/src/app/app.config.ts
+++ b/apps/self-service/src/app/app.config.ts
@@ -7,16 +7,29 @@ import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
-export const appConfig: ApplicationConfig = {
- providers: [
- provideBrowserGlobalErrorListeners(),
- provideRouter(appRoutes),
- provideHttpClient(withInterceptors([authInterceptor()])),
- // Dev defaults (host ports). The compose-served app overrides these for the stack (S-08d).
- provideDigiadAuth({
- authority: 'http://localhost:8180/realms/digid',
- redirectUrl: typeof window !== 'undefined' ? window.location.origin : '/',
- secureApiOrigin: 'http://localhost:8080',
- }),
- ],
-};
+/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
+export interface RuntimeConfig {
+ /** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
+ authority: string;
+}
+
+/**
+ * Build the app providers from runtime config. `redirectUrl` and `secureApiOrigin` are the app's own
+ * origin: the app is served same-origin as the BFF (nginx proxies /self-service + /openbaar), so the
+ * api-client's relative calls stay same-origin and the token interceptor attaches to them.
+ */
+export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
+ const origin = typeof window !== 'undefined' ? window.location.origin : '/';
+ return {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideRouter(appRoutes),
+ provideHttpClient(withInterceptors([authInterceptor()])),
+ provideDigiadAuth({
+ authority: runtime.authority,
+ redirectUrl: origin,
+ secureApiOrigin: origin,
+ }),
+ ],
+ };
+}
diff --git a/apps/self-service/src/main.ts b/apps/self-service/src/main.ts
index 190f341..29b0198 100644
--- a/apps/self-service/src/main.ts
+++ b/apps/self-service/src/main.ts
@@ -1,5 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
-import { appConfig } from './app/app.config';
import { App } from './app/app';
+import { appConfig, type RuntimeConfig } from './app/app.config';
-bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+// Load environment config before bootstrap so the OIDC authority is set per environment
+// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
+fetch('config.json')
+ .then((response) => response.json() as Promise)
+ .then((config) => bootstrapApplication(App, appConfig(config)))
+ .catch((err) => console.error(err));
From 4f311c9b5a86cec1b0a6168434ba89c799f82f0f Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Wed, 1 Jul 2026 14:05:10 +0200
Subject: [PATCH 02/11] ci(portal-self-service): serve the self-service app in
compose (refs #68)
Add the self-service nginx service (build the app image, depends_on bff healthy +
keycloak started, health-checked, host port 8140). Add it to WAIT_SVCS and the CI
log dump.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.gitea/workflows/ci.yaml | 2 +-
Makefile | 2 +-
infra/docker-compose.yml | 24 ++++++++++++++++++++++++
3 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index eff1cd1..cedfa6a 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -127,7 +127,7 @@ jobs:
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
- run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true
+ run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down
if: always()
run: make down
diff --git a/Makefile b/Makefile
index 01b48ae..5e08b27 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
-WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api
+WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 0c10982..7a51c3f 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -433,6 +433,30 @@ services:
condition: service_healthy
networks: [cg]
+ # ── Self-Service portal (S-08d) ────────────────────────────────────────────
+ # nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
+ # (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
+ # token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
+ self-service:
+ build:
+ context: ..
+ dockerfile: apps/self-service/Dockerfile
+ image: register-referentie/self-service:dev
+ ports:
+ - "8140:80"
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ start_period: 10s
+ depends_on:
+ bff:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ networks: [cg]
+
volumes:
oz-db:
nrc-db:
From 490e7347b044e7f44d0fc2843abc6ee916b526ac Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Wed, 1 Jul 2026 14:08:10 +0200
Subject: [PATCH 03/11] test(e2e): walking-skeleton Playwright happy path +
verify-e2e lane (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
tests/e2e Playwright spec drives DigiD login (jan-burger/test123) → submit →
confirmation against the compose-served portal. run-e2e-check.sh runs it inside the
compose network (node container, browser installed at runtime) so the token issuer
(keycloak:8080) matches the BFF authority (ADR-0010). Wired as verify-e2e (Makefile +
verify chain + a verify-stack CI step).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.gitea/workflows/ci.yaml | 2 ++
.gitignore | 5 +++++
Makefile | 8 +++++++-
infra/run-e2e-check.sh | 25 +++++++++++++++++++++++++
tests/e2e/package.json | 8 ++++++++
tests/e2e/playwright.config.ts | 16 ++++++++++++++++
tests/e2e/registration.spec.ts | 21 +++++++++++++++++++++
7 files changed, 84 insertions(+), 1 deletion(-)
create mode 100755 infra/run-e2e-check.sh
create mode 100644 tests/e2e/package.json
create mode 100644 tests/e2e/playwright.config.ts
create mode 100644 tests/e2e/registration.spec.ts
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index cedfa6a..4e3a624 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -124,6 +124,8 @@ jobs:
run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
+ - name: Self-service e2e (Playwright, login → submit → success)
+ run: make verify-e2e
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
diff --git a/.gitignore b/.gitignore
index c206ae3..30b841d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,8 @@ vite.config.*.timestamp*
vitest.config.*.timestamp*
.angular
+
+# Playwright e2e (installed/generated in-container or on local runs)
+tests/e2e/node_modules/
+tests/e2e/test-results/
+tests/e2e/playwright-report/
diff --git a/Makefile b/Makefile
index 5e08b27..77be727 100644
--- a/Makefile
+++ b/Makefile
@@ -153,6 +153,11 @@ verify-domain:
verify-bff:
bash infra/run-bff-check.sh
+## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
+## submit → confirmation, driven inside the compose network.
+verify-e2e:
+ bash infra/run-e2e-check.sh
+
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -165,7 +170,8 @@ verify:
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \
- && bash infra/run-bff-check.sh || rc=$$?; \
+ && bash infra/run-bff-check.sh \
+ && bash infra/run-e2e-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc'
diff --git a/infra/run-e2e-check.sh b/infra/run-e2e-check.sh
new file mode 100755
index 0000000..56a230b
--- /dev/null
+++ b/infra/run-e2e-check.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+#
+# Walking-skeleton e2e (S-08d) against an ALREADY-RUNNING full stack: drive the self-service portal
+# in a real browser through mock-DigiD login → submit → confirmation (login → BFF → domain).
+#
+# Runs Playwright INSIDE the compose network (a node container on `cg`), so the browser reaches
+# Keycloak by service name (keycloak:8080) — the same authority the BFF validates against, so the
+# token issuer matches (ADR-0010). The spec is copied into the container (docker cp), not mounted,
+# so it leaves no root-owned files on the host. The caller owns stack bring-up + teardown.
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+root="$(cd "$here/.." && pwd)"
+
+ss="$(docker ps -q --filter 'name=self-service' | head -1)"
+[ -n "$ss" ] || { echo "ERROR: no running self-service container — bring the stack up first" >&2; exit 1; }
+net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ss" | head -1)"
+echo ">> running Playwright e2e on network $net against http://self-service"
+
+cid="$(docker create --network "$net" -w /e2e --ipc=host \
+ -e SELF_SERVICE_URL=http://self-service \
+ node:24 sh -c 'npm install --no-audit --no-fund && npx playwright install --with-deps chromium && npx playwright test')"
+trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
+docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
+docker start -a "$cid"
diff --git a/tests/e2e/package.json b/tests/e2e/package.json
new file mode 100644
index 0000000..1732cc1
--- /dev/null
+++ b/tests/e2e/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "e2e",
+ "private": true,
+ "description": "Playwright walking-skeleton e2e (S-08d) — run inside the compose network by infra/run-e2e-check.sh.",
+ "devDependencies": {
+ "@playwright/test": "1.61.1"
+ }
+}
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
new file mode 100644
index 0000000..45a448f
--- /dev/null
+++ b/tests/e2e/playwright.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig, devices } from '@playwright/test';
+
+// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
+// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
+export default defineConfig({
+ testDir: '.',
+ timeout: 90_000,
+ expect: { timeout: 15_000 },
+ retries: 1,
+ reporter: [['list']],
+ use: {
+ baseURL: process.env.SELF_SERVICE_URL ?? 'http://self-service',
+ trace: 'on-first-retry',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts
new file mode 100644
index 0000000..e446bdb
--- /dev/null
+++ b/tests/e2e/registration.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from '@playwright/test';
+
+// Walking-skeleton happy path (S-08d): a zorgprofessional logs in via mock DigiD and submits a
+// registration through the self-service portal → BFF → domain. The confirmation shows the reference.
+test('DigiD login → submit → confirmation', async ({ page }) => {
+ // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
+ await page.goto('/');
+
+ // Keycloak's default login form (stable ids across themes).
+ await page.locator('#username').fill('jan-burger');
+ await page.locator('#password').fill('test123');
+ await page.locator('#kc-login').click();
+
+ // Back on the portal, authenticated.
+ await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
+
+ await page.getByRole('button', { name: /indienen/i }).click();
+
+ // The BFF accepted it and the page shows the confirmation.
+ await expect(page.getByText(/ontvangen/i)).toBeVisible();
+});
From d3f23a4da33f9ebba55c677147ea51372b891a58 Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Wed, 1 Jul 2026 14:09:04 +0200
Subject: [PATCH 04/11] docs(portal-self-service): serving/e2e decisions +
walking-skeleton demo note (refs #68)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/demo-script.md | 22 ++++++++++++++++++++++
docs/frontend-decisions.md | 21 +++++++++++++++++++++
2 files changed, 43 insertions(+)
diff --git a/docs/demo-script.md b/docs/demo-script.md
index 8dfcd4d..b457be5 100644
--- a/docs/demo-script.md
+++ b/docs/demo-script.md
@@ -5,6 +5,28 @@ copy-pasteable walkthrough against a local `make up` stack.
---
+## S-08d — Walking skeleton complete: browser → submit, end-to-end
+
+**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
+runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton
+(portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection).
+
+```bash
+# 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180).
+make up
+
+# 2. Automated happy path — Playwright, inside the compose network (issuer-consistent):
+make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation
+
+# 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen".
+open http://localhost:8140
+```
+
+> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
+> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
+
+---
+
## S-08c — Self-service submit form (NL Design System + DigiD)
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
diff --git a/docs/frontend-decisions.md b/docs/frontend-decisions.md
index 0768bb1..8a54823 100644
--- a/docs/frontend-decisions.md
+++ b/docs/frontend-decisions.md
@@ -72,3 +72,24 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
- **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 nginx, 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 → nginx forwards to the
+ BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
+ the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
+- **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 `node`
+ 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). Chromium is installed at
+ runtime, so there's no Playwright-image-version pinning to keep in sync. 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.
+- `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.
From be016f920c4b909d03dafeb69f2490c2cfd2bf16 Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Wed, 1 Jul 2026 14:37:56 +0200
Subject: [PATCH 05/11] fix(portal-self-service): health-check nginx over IPv4
(127.0.0.1) (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
nginx listens on IPv4 only (listen 80), but 'localhost' inside the container resolves
to ::1 first, so the wget healthcheck got connection-refused and self-service never
went healthy — timing out the CI stack bring-up. Probe 127.0.0.1 instead.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
infra/docker-compose.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 7a51c3f..4dceacd 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -445,7 +445,8 @@ services:
ports:
- "8140:80"
healthcheck:
- test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
+ # 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
From 2e00ad38ba13e9a25798541c07fc02bb7e0e61bb Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Mon, 13 Jul 2026 12:51:56 +0200
Subject: [PATCH 06/11] ci(portal-self-service): run Vitest ahead of the
production build to stop worker-start timeout (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The frontend lane ran `nx run-many -t lint test build`, so the ~5min
self-service production build shared nx's task pool with the Vitest test
worker. @angular/build:unit-test's Vitest worker has hard-coded 60s/90s
startup timeouts (not configurable); on a CPU-constrained CI runner the
concurrent build starved the worker and it failed with "Timeout waiting
for worker to respond" — flaky, since it passed on the prior commit.
Split the target into a light lint+test phase and a separate build phase
so tests get CPU and the worker starts well inside its window.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
Makefile | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 77be727..3075548 100644
--- a/Makefile
+++ b/Makefile
@@ -50,9 +50,16 @@ endif
ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
+# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
+# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
+# not configurable. When the ~5min production build shares the run-many pool, it
+# starves that worker of CPU on constrained CI runners and Vitest fails with
+# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
+# heavy build's back so the worker starts well inside its window.
frontend:
pnpm install --frozen-lockfile
- pnpm nx run-many -t lint test build
+ pnpm nx run-many -t lint test
+ pnpm nx run-many -t build
## lint: verify formatting (no changes)
lint:
From 39923e0e68ad98007892c483d422288db1ea47c0 Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Mon, 13 Jul 2026 13:37:31 +0200
Subject: [PATCH 07/11] fix(e2e): treat the http portal origin as secure so
DigiD PKCE login works (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The walking-skeleton e2e timed out waiting for the Keycloak login form
(`#username`). Root cause: in the compose 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 undefined. angular-auth-
oidc-client needs SubtleCrypto to build the PKCE code challenge, so
`authorize()` threw ("Cannot read properties of undefined (reading 'digest')")
and the login redirect never fired.
Production serves the portal over HTTPS, where this works. Instead of
terminating TLS in the throwaway e2e stack, tell Chromium to treat the origin
as secure via --unsafely-treat-insecure-origin-as-secure. The flag is only
honoured by the full Chromium build (new headless), not Playwright's default
headless-shell, so pin channel: 'chromium'.
Verified against a minimal in-network stack (keycloak + self-service): login
redirect now reaches the Keycloak form, and the full login → token exchange →
authenticated portal renders with no console errors.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/frontend-decisions.md | 8 ++++++++
tests/e2e/playwright.config.ts | 14 +++++++++++++-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/docs/frontend-decisions.md b/docs/frontend-decisions.md
index 8a54823..9c31125 100644
--- a/docs/frontend-decisions.md
+++ b/docs/frontend-decisions.md
@@ -91,5 +91,13 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
runtime, so there's no Playwright-image-version pinning to keep in sync. 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.
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
index 45a448f..6f6a37d 100644
--- a/tests/e2e/playwright.config.ts
+++ b/tests/e2e/playwright.config.ts
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
+const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
+
export default defineConfig({
testDir: '.',
timeout: 90_000,
@@ -9,8 +11,18 @@ export default defineConfig({
retries: 1,
reporter: [['list']],
use: {
- baseURL: process.env.SELF_SERVICE_URL ?? 'http://self-service',
+ baseURL,
trace: 'on-first-retry',
+ // The portal is served over plain HTTP on a non-localhost origin (http://self-service) inside the
+ // compose network, so it is NOT a secure context — and Web Crypto (`crypto.subtle`) is undefined
+ // there. angular-auth-oidc-client needs SubtleCrypto to build the PKCE code challenge, so
+ // `authorize()` throws and the login redirect never fires (the login form never appears). In
+ // production the portal runs behind HTTPS, where this works. Rather than terminate TLS in the
+ // throwaway e2e stack, tell Chromium to treat this origin as secure — which faithfully emulates
+ // the production HTTPS context. This flag is only honoured by the full Chromium build (new
+ // headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
+ channel: 'chromium',
+ launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
From 0e6c7d20663d48e06b54c3428a2e83a2b6f38b0f Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Mon, 13 Jul 2026 14:09:15 +0200
Subject: [PATCH 08/11] fix(portal-self-service): attach the DigiD token to
relative BFF calls (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After login the submit silently did nothing: the confirmation ("...is
ontvangen...") never rendered because the POST to the BFF went out with no
Authorization header, so the BFF rejected it and the no-error-handler
subscribe left the page unchanged.
Root cause: angular-auth-oidc-client's interceptor attaches the token when
`req.url.startsWith(secureRoute)`. The api-client calls the BFF with RELATIVE
URLs (same-origin via the nginx proxy), so `req.url` is `/self-service/...` —
but secureRoutes was configured as the app ORIGIN (`http://self-service`),
which a relative URL never starts with. No match → no token.
Configure secureRoutes with the relative `/self-service/` prefix instead. The
unit test mocked the api-client, so only the walking-skeleton e2e exercises the
real token attachment — now green.
Verified against a focused stack (keycloak + self-service + real BFF + stub
domain): the submit now carries the bearer token, the BFF forwards to the
domain, and the portal shows the confirmation with the returned reference.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/self-service/src/app/app.config.ts | 10 ++++++----
libs/auth/src/lib/digid-auth.providers.ts | 11 ++++++++---
2 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/apps/self-service/src/app/app.config.ts b/apps/self-service/src/app/app.config.ts
index a4b7577..c60e7bf 100644
--- a/apps/self-service/src/app/app.config.ts
+++ b/apps/self-service/src/app/app.config.ts
@@ -14,9 +14,11 @@ export interface RuntimeConfig {
}
/**
- * Build the app providers from runtime config. `redirectUrl` and `secureApiOrigin` are the app's own
- * origin: the app is served same-origin as the BFF (nginx proxies /self-service + /openbaar), so the
- * api-client's relative calls stay same-origin and the token interceptor attaches to them.
+ * Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
+ * redirects back). The app is served same-origin as the BFF (nginx proxies /self-service + /openbaar),
+ * so the api-client uses **relative** URLs — hence `secureRoutes` is the relative `/self-service/`
+ * prefix (the guarded BFF route), not the origin: the interceptor matches on `req.url`, which stays
+ * relative, so an origin would never match and the token would not be attached.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
@@ -28,7 +30,7 @@ export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
provideDigiadAuth({
authority: runtime.authority,
redirectUrl: origin,
- secureApiOrigin: origin,
+ secureRoutes: ['/self-service/'],
}),
],
};
diff --git a/libs/auth/src/lib/digid-auth.providers.ts b/libs/auth/src/lib/digid-auth.providers.ts
index 0f1cb21..15f5ec2 100644
--- a/libs/auth/src/lib/digid-auth.providers.ts
+++ b/libs/auth/src/lib/digid-auth.providers.ts
@@ -13,8 +13,13 @@ export interface DigiadAuthOptions {
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
- /** The BFF origin whose requests get the bearer token attached (secure route). */
- secureApiOrigin: string;
+ /**
+ * Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
+ * **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
+ * (e.g. `/self-service/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
+ * relative `req.url` never starts with an absolute origin.
+ */
+ secureRoutes: string[];
}
/**
@@ -35,7 +40,7 @@ export function provideDigiadAuth(options: DigiadAuthOptions): EnvironmentProvid
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
- secureRoutes: [options.secureApiOrigin],
+ secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
From 5bf25f094d2fb30b48fd624286af114cdce38d8c Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Mon, 13 Jul 2026 14:32:30 +0200
Subject: [PATCH 09/11] test(portal-self-service): submit surfaces BFF failures
instead of swallowing them (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Failing test: when postSelfServiceRegistrations errors, the page should show an
alert, not the confirmation, and keep the submit button available for retry.
Currently submit() has no error handler, so the rejection is swallowed and the
page silently stays put — exactly the failure mode that hid the missing-token
bug behind a 90s e2e timeout.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../app/registration/registration-page.spec.ts | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/apps/self-service/src/app/registration/registration-page.spec.ts b/apps/self-service/src/app/registration/registration-page.spec.ts
index 4c1a2b2..284f7da 100644
--- a/apps/self-service/src/app/registration/registration-page.spec.ts
+++ b/apps/self-service/src/app/registration/registration-page.spec.ts
@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
-import { of } from 'rxjs';
+import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe';
@@ -43,6 +43,19 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
});
+ it('shows an error and keeps the submit available when the BFF call fails', async () => {
+ const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
+ await render(RegistrationPage, { providers: p });
+
+ fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
+
+ expect(post).toHaveBeenCalledTimes(1);
+ // The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
+ expect(await screen.findByRole('alert')).toBeTruthy();
+ expect(screen.queryByText(/ontvangen/i)).toBeNull();
+ expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
+ });
+
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.
From 7e152e4432317e55669a6b1f9649f215fb925b2f Mon Sep 17 00:00:00 2001
From: Niek Otten
Date: Mon, 13 Jul 2026 14:33:09 +0200
Subject: [PATCH 10/11] feat(portal-self-service): surface submit failures with
a retryable alert (refs #68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add an error branch to submit(): on a failed BFF call, set a `failed` signal,
re-enable the button, and render a role="alert" message so the user knows the
submit did not go through and can retry — instead of the click silently doing
nothing.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/app/registration/registration-page.html | 5 +++++
.../src/app/registration/registration-page.ts | 17 +++++++++++++----
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/apps/self-service/src/app/registration/registration-page.html b/apps/self-service/src/app/registration/registration-page.html
index 3aa25cf..a8b6627 100644
--- a/apps/self-service/src/app/registration/registration-page.html
+++ b/apps/self-service/src/app/registration/registration-page.html
@@ -8,6 +8,11 @@
} @else {
U bent ingelogd met BSN {{ bsn() }}.
+ @if (failed()) {
+
+ Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
+