Compare commits

..

86 Commits

Author SHA1 Message Date
2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
All checks were successful
CI / build (pull_request) Successful in 56s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
CI / lint (pull_request) Successful in 5m43s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
The verify-e2e lane downloaded ~150 MB of Chromium (npx playwright install) on
every verify-stack run. Use the official mcr.microsoft.com/playwright image with
browsers pre-baked; npm install still pins @playwright/test from tests/e2e, and
the image tag is kept in lockstep with that version. Verified the exact
create + docker cp + start flow launches the baked browser with no download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
60df0845aa ci: cache the NuGet package store across the .NET jobs (refs #73)
lint, build, unit and mutation each restored packages from the network on every
run. There are no lock files (so setup-dotnet's built-in cache doesn't apply), so
cache ~/.nuget/packages keyed on the project files via actions/cache. Pinned @v3
to avoid the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); the
cache is best-effort, so a miss simply restores from the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
2a746736dc Merge pull request 'test(e2e): serve the portal + walking-skeleton Playwright e2e (closes #68)' (#72) from feat/68-e2e into main
All checks were successful
CI / verify-stack (push) Successful in 7m19s
CI / lint (push) Successful in 1m10s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m52s
CI / mutation (push) Successful in 4m11s
Reviewed-on: #72
2026-07-13 13:20:57 +00:00
986e36bc7d test(portal-self-service): guard that the DigiD token attaches to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 2m10s
CI / mutation (pull_request) Successful in 3m58s
CI / verify-stack (pull_request) Successful in 7m48s
The token-attachment bug (secureRoutes set to the app origin, which a relative
api-client URL never matches) was only caught by the full-stack e2e. Add a fast
unit guard: drive the REAL angular-auth-oidc-client interceptor and the REAL
api-client against the production route value, faking only the config source and
the token storage. Asserts the bearer token rides the relative /self-service/
call and is withheld from the anonymous /openbaar/ call.

Extract the value to a shared SECURE_API_ROUTES constant so the test binds to
exactly what the app configures. Verified the guard fails (Authorization null)
if the value regresses to an origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:00:32 +02:00
7e152e4432 feat(portal-self-service): surface submit failures with a retryable alert (refs #68)
All checks were successful
CI / build (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 1m11s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m49s
CI / mutation (pull_request) Successful in 4m3s
CI / verify-stack (pull_request) Successful in 7m42s
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) <noreply@anthropic.com>
2026-07-13 14:33:09 +02:00
5bf25f094d test(portal-self-service): submit surfaces BFF failures instead of swallowing them (refs #68)
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) <noreply@anthropic.com>
2026-07-13 14:32:30 +02:00
0e6c7d2066 fix(portal-self-service): attach the DigiD token to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 1m3s
CI / verify-stack (pull_request) Successful in 7m59s
CI / frontend (pull_request) Successful in 1m48s
CI / mutation (pull_request) Successful in 3m57s
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) <noreply@anthropic.com>
2026-07-13 14:09:15 +02:00
39923e0e68 fix(e2e): treat the http portal origin as secure so DigiD PKCE login works (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m47s
CI / mutation (pull_request) Successful in 4m2s
CI / verify-stack (pull_request) Failing after 7m51s
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) <noreply@anthropic.com>
2026-07-13 13:37:31 +02:00
2e00ad38ba ci(portal-self-service): run Vitest ahead of the production build to stop worker-start timeout (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Successful in 4m5s
CI / verify-stack (pull_request) Failing after 10m20s
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) <noreply@anthropic.com>
2026-07-13 12:51:56 +02:00
be016f920c fix(portal-self-service): health-check nginx over IPv4 (127.0.0.1) (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Failing after 7m11s
CI / mutation (pull_request) Successful in 3m59s
CI / verify-stack (pull_request) Failing after 7m47s
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) <noreply@anthropic.com>
2026-07-01 14:37:56 +02:00
d3f23a4da3 docs(portal-self-service): serving/e2e decisions + walking-skeleton demo note (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 52s
CI / unit (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 1m31s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:09:04 +02:00
490e7347b0 test(e2e): walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
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) <noreply@anthropic.com>
2026-07-01 14:08:10 +02:00
4f311c9b5a 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) <noreply@anthropic.com>
2026-07-01 14:05:10 +02:00
a55ba1160d feat(portal-self-service): runtime config + nginx serve/proxy image (refs #68)
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) <noreply@anthropic.com>
2026-07-01 14:03:59 +02:00
4416d1f4ed Merge pull request 'feat(portal-self-service): NL DS + DigiD self-service submit form (closes #67)' (#71) from feat/67-self-service-form into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 51s
CI / unit (push) Successful in 1m1s
CI / frontend (push) Successful in 1m31s
CI / mutation (push) Successful in 3m56s
CI / verify-stack (push) Successful in 5m29s
Reviewed-on: #71
2026-07-01 11:52:32 +00:00
074101e836 fix(portal-self-service): run checkAuth() at startup to end the login redirect loop (refs #67)
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m56s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Successful in 4m34s
Without an app-init auth check, the DigiD callback (?code=…) was never processed, so
the guard kept seeing 'not authenticated' and re-triggered login — an infinite
redirect loop. Add withAppInitializerAuthCheck() so checkAuth() runs before the router
and guard, establishing the session on the callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:26:42 +02:00
5089c2aea6 fix(portal-self-service): re-export the full Utrecht package from libs/ui (refs #67)
Some checks failed
CI / lint (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 59s
CI / mutation (pull_request) Successful in 3m54s
CI / frontend (pull_request) Successful in 1m30s
CI / verify-stack (pull_request) Has been cancelled
Importing UtrechtComponentsModule pulls every component it exports into the AOT
compiler scope, so all must be resolvable through the ui barrel; a partial
re-export failed a fresh build with NG3004 (masked locally by the Nx build cache,
surfaced by nx serve / a --skip-nx-cache build). Re-export the whole package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:23:17 +02:00
29f3dcc6cf docs(portal-self-service): record NL DS + DigiD decisions and demo note (refs #67)
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 50s
CI / unit (pull_request) Successful in 1m0s
CI / verify-stack (pull_request) Successful in 5m46s
CI / frontend (pull_request) Successful in 1m27s
CI / mutation (pull_request) Successful in 3m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:13:04 +02:00
2c196245c2 feat(portal-self-service): implement the DigiD registration submit page (refs #67)
RegistrationPage shows the signed-in BSN and submits to the BFF via the generated
api-client, confirming with the returned reference; built from NL Design System
(Utrecht) components. Wire the guarded route + app providers (DigiD OIDC + token
interceptor + HttpClient), the NL DS theme, and lang=nl. Component tests
(Testing Library) + axe (WCAG 2.1 AA) pass; a guard test covers libs/auth. Replace
the demo eslint depConstraints (scope:shop/shared) with a permissive default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:12:09 +02:00
72c2bdfae7 test(portal-self-service): DigiD-guarded registration submit page (refs #67)
Scaffold libs/ui (NL Design System via Utrecht components) and libs/auth (DigiD OIDC
over angular-auth-oidc-client: mockable AuthService, provider, token interceptor,
authenticated guard). Failing component + axe tests for the RegistrationPage: it must
show the signed-in BSN, submit to the BFF (mocked api-client) and confirm, with no
WCAG 2.1 AA violations. The page is a stub, so the behaviour tests fail; green follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:02:29 +02:00
311aab0aba Merge pull request 'feat(api-client): generated BFF client library (closes #66)' (#70) from feat/66-api-client into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 48s
CI / unit (push) Successful in 54s
CI / frontend (push) Successful in 1m16s
CI / mutation (push) Successful in 3m48s
CI / verify-stack (push) Successful in 5m46s
Reviewed-on: #70
2026-07-01 10:51:01 +00:00
fcdb117768 docs(api-client): record the orval generator choice (refs #66)
All checks were successful
CI / lint (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 49s
CI / unit (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 1m17s
CI / mutation (pull_request) Successful in 3m48s
CI / verify-stack (pull_request) Successful in 5m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:32:42 +02:00
c3f0710a18 feat(api-client): expose the generated BFF client + repeatable generate target (refs #66)
The lib barrel exports the generated BffApiV1Service + models (SubmitAccepted,
OpenbaarEntry), so the app can inject a typed client for the BFF. Add an
'api-client:generate' target (orval) to regenerate from services/bff/openapi.json;
generation is idempotent. Tests (HttpClientTesting) now pass: POST /self-service/
registrations and GET /openbaar/register with the query, mapping typed responses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:32:07 +02:00
7c363099ff test(api-client): generated BFF client is exposed and calls the endpoints (refs #66)
Scaffold libs/api-client (Nx Angular lib) and generate a typed HttpClient client
from services/bff/openapi.json with orval (node-based; Angular target integrates
with HttpClient interceptors for the S-08c auth token). A failing test drives the
public API: it expects an injectable BffApiV1Service to POST /self-service/registrations
and GET /openbaar/register (via HttpClientTesting), but the lib barrel doesn't export
the client yet, so it fails. Normalise the vitest target to 'test'. Green exposes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:29:57 +02:00
a069ab07a2 Merge pull request 'feat(portal-self-service): Nx workspace + self-service app scaffold (closes #65)' (#69) from feat/65-nx-workspace into main
All checks were successful
CI / lint (push) Successful in 1m7s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m10s
CI / mutation (push) Successful in 3m52s
CI / verify-stack (push) Successful in 5m37s
Reviewed-on: #69
2026-07-01 10:22:53 +00:00
34969659f7 fix(portal-self-service): keep dotnet format green under the shared .editorconfig (refs #65)
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 59s
CI / frontend (pull_request) Successful in 2m27s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Successful in 5m43s
The imported Nx .editorconfig applied a global 2-space indent + charset=utf-8 to
all files, so dotnet format flagged every 4-space C# line and the BOM'd EF migration.
Scope it: [*.cs] keeps 4-space, and the global charset rule is dropped (utf-8 is the
default; the BOM'd generated migration is left alone). Frontend files stay 2-space.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:05:45 +02:00
fd90c4abe2 docs(portal-self-service): frontend-decisions + demo note for S-08a (refs #65)
Some checks failed
CI / lint (pull_request) Failing after 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m5s
CI / mutation (pull_request) Has been cancelled
CI / verify-stack (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Record the workspace/tooling decisions (pnpm, Nx scoped to apps/+libs/, Vitest,
no @nx/docker, no Nx Cloud, Gitea-only) and a demo note for running the placeholder
app. NL DS deferred to S-08c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:58:02 +02:00
3824f85af6 ci(portal-self-service): Nx frontend lane (lint/test/build) (refs #65)
Add a make frontend target (pnpm install --frozen-lockfile + nx run-many -t lint
test build) and a CI 'frontend' job (pnpm + Node 24, pinned action URLs). Wire
frontend into make ci. The .NET lanes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:56:59 +02:00
ef877ebc80 feat(portal-self-service): self-service portal placeholder page (refs #65)
Replace the generated Nx welcome page with a minimal self-service placeholder
(Dutch 'Zelfservice — BIG-registratie' heading + router-outlet); drop nx-welcome.
The login + submit form arrive in S-08c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:55:46 +02:00
9c961f9a13 test(portal-self-service): self-service portal placeholder renders (refs #65)
Bootstrap the Nx (pnpm) workspace at the repo root with the self-service Angular
app (standalone + signals, Vitest via @angular/build, ESLint) — the frontend
foundation. Nx is scoped to apps/+libs/ only; the .NET services stay on
dotnet/Makefile (no @nx/docker inference). A failing test asserts the app renders
a 'Zelfservice' heading; it still shows the generated Nx welcome page, so it fails.
Green commit implements the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:54:48 +02:00
0b82841b14 docs(backlog): split S-08 into S-08a-d (refs #65)
S-08 (#9) bundled the Nx bootstrap, generated client, NL DS + DigiD form and a
full-stack Playwright e2e — past the 1-2 day line (CLAUDE.md §13). Closed #9 in
favour of #65 (Nx workspace + CI lane), #66 (api-client), #67 (submit form + a11y),
#68 (Playwright e2e + compose serving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:37:47 +02:00
5a4331a416 Merge pull request 'feat(bff): BFF with one endpoint per portal + OIDC validation (closes #8)' (#64) from feat/8-bff into main
All checks were successful
CI / lint (push) Successful in 1m7s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 56s
CI / mutation (push) Successful in 3m52s
CI / verify-stack (push) Successful in 5m46s
Reviewed-on: #64
2026-07-01 09:32:44 +00:00
96d447832f docs(bff): demo note for the BFF front door (S-07) (refs #8)
All checks were successful
CI / lint (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 58s
CI / unit (pull_request) Successful in 1m2s
CI / mutation (pull_request) Successful in 3m47s
CI / verify-stack (pull_request) Successful in 5m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:15:39 +02:00
a07d8277d6 ci(bff): compose wiring, verify-bff live check, mutation baseline (refs #8)
Wire the bff service in compose (Keycloak authority + downstream domain/projection
URLs, depends_on domain/projection healthy + keycloak started). run-bff-check.sh
verifies the BFF end-to-end against the up stack: 401 without a token, 202 with a
real digid token minted via direct grant against keycloak:8080 (host-consistent
issuer, ADR-0010), and an anonymous public-safe openbaar register (never a bsn).
Wired as verify-bff (Makefile + verify chain + CI step). Stryker baseline for the
BFF's pure logic (OpenbaarProjection) at 100% (break 90); Program/HTTP adapters are
covered by the endpoint tests + verify-bff. CI uploads the bff mutation report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:15:05 +02:00
69d6e80378 feat(bff): committed OpenAPI contract + drift guard (refs #8)
Document typed responses (202 SubmitAccepted / 400 / 401 on self-service; 200
OpenbaarEntry[] on openbaar) so the generated spec carries real schemas for S-08's
client. A document transformer clears the auto-populated servers block so the spec
is host-independent and deterministic. Commit services/bff/openapi.json and add a
test asserting it matches the served /openapi/v1.json (fails on drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:07:55 +02:00
5d32d4f15e test(bff): acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
Use-case-level BDD (Reqnroll) driving the real BFF over HTTP with fake downstreams
and locally-minted tokens: a valid DigiD token is accepted and the bsn forwarded
to the domain; a tokenless submit is 401; the openbaar register is anonymous and
never exposes the bsn (ADR-0010). Real Keycloak validation is the verify-bff check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:03:37 +02:00
d767430ad7 feat(bff): implement self-service submit and openbaar lookup (refs #8)
POST /self-service/registrations requires a valid digid JWT, reads the bsn claim
and forwards it to the domain, returning 202. GET /openbaar/register is anonymous
and returns OpenbaarProjection.PublicView — rows filtered by q and mapped to the
public-safe id+status only (bsn/naam never exposed). JwtBearer validates
signature/issuer/expiry against the Keycloak digid authority (§8.3, ADR-0010).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:00:56 +02:00
751ca006a7 test(bff): endpoints, JWT auth and public-safe projection (refs #8)
Failing tests for the BFF walking-skeleton endpoints:
- POST /self-service/registrations rejects missing/malformed/wrong-key/expired
  tokens (401) and, with a valid digid token, forwards the bsn to the domain and
  returns 202 (WebApplicationFactory + a local test signing key, ADR-0010).
- GET /openbaar/register serves public-safe rows anonymously (never the bsn) and
  filters by q.
- OpenbaarProjection.PublicView (pure) filters by id and maps to id+status only.

Endpoints and PublicView are stubs so the tests compile and fail on their
assertions; the green commit implements them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:59:55 +02:00
fea806848b arch(bff): ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
The BFF is the portals' only backend (§8.3): it validates Keycloak digid-realm
JWTs on POST /self-service/registrations (extracting bsn → domain), leaves
GET /openbaar/register anonymous (public lookup, S-09), and fans out to the
domain and projection over typed HTTP clients. Tests mint tokens with a test
signing key; real Keycloak validation is a live-stack verify-bff check. Records
the container OIDC issuer-mismatch wrinkle. OpenAPI is generated + committed for
the S-08 client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:51:59 +02:00
2f5d656b54 Merge pull request 'feat(domain): BIG Domain Service skeleton with the Registration aggregate (closes #6)' (#61) from feat/6-domain-service into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build (push) Successful in 50s
CI / unit (push) Successful in 52s
CI / mutation (push) Successful in 3m14s
CI / verify-stack (push) Successful in 5m42s
Reviewed-on: #61
2026-07-01 08:46:21 +00:00
72efab3ae0 ci: retrigger CI after gitea restart (refs #6)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build (pull_request) Successful in 50s
CI / unit (pull_request) Successful in 57s
CI / mutation (pull_request) Successful in 3m36s
CI / verify-stack (pull_request) Successful in 5m47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:09:02 +02:00
1edd34e2db ci: retrigger CI (refs #6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:04:55 +02:00
f885e0a3be ci: retrigger after runner cleanup (refs #6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:03:14 +02:00
ac874bf746 ci(mutation): make Stryker report upload best-effort (refs #62)
Some checks failed
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 55s
CI / mutation (pull_request) Successful in 3m16s
CI / lint (pull_request) Failing after 14m2s
CI / verify-stack (pull_request) Successful in 6m12s
The Gitea artifact backend returns 500 to actions/upload-artifact@v3 (server-side,
distinct from the @v4 GHES guard). With if: always() that 500 failed the whole
mutation job even though the ratchet passed — red on main and on every PR. Mark the
three report uploads continue-on-error: true so the mutation *gate* stays the Stryker
ratchet (make mutation's exit code), not the report upload. Documented in
gitea-actions-gotchas.md §4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:32:16 +02:00
67f0ffb88d docs(domain): demo note for submitting a registration (S-05) (refs #6)
Some checks failed
CI / lint (pull_request) Successful in 1m4s
CI / build (pull_request) Successful in 48s
CI / unit (pull_request) Successful in 58s
CI / mutation (pull_request) Failing after 17m2s
CI / verify-stack (pull_request) Successful in 6m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:32:29 +02:00
5a3f28ac6d ci(domain): containerize, wire into compose, and verify end-to-end (refs #6)
Dockerfile (multi-stage, .NET 10) + .dockerignore for the BIG Domain Service; a
'domain' service in infra/docker-compose.yml (health-checked, depends on acl healthy
and flowable-init completed). run-domain-check.sh drives the full path against the up
stack — seed a published zaaktype, recreate the acl pointed at it (host-consistent),
POST /registrations, and assert the worker opens a zaak and records it. Wired as the
verify-domain Makefile target + a verify-stack CI step; domain added to WAIT_SVCS and
the log dump. seed_catalogus.py now emits a machine-readable ZAAKTYPE_URL line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:31:45 +02:00
e9a873c152 test(domain): mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
Stryker.NET config for the domain service (break 90, the repo's ratchet floor),
excluding the OpenZaakJobPump hosted-shell from mutation. Hardened the unit tests
to kill survivors — Basic-credential value, variable types, null/failure response
paths, option defaults, guard clauses, save counts and log output — leaving only
two documented equivalent mutants (Stryker-disabled). make mutation runs the domain
ratchet and CI uploads its report alongside the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:24:56 +02:00
79dcd8f14b test(domain): acceptance scenario for submitting a registration (refs #6)
Use-case-level BDD (Reqnroll) for S-05: a zorgprofessional submits a registration;
the Domain Service starts the registratie process and the OpenZaakAanmaken external
task opens a zaak via the ACL, recorded on the aggregate (ADR-0009). Driven against
in-memory Workflow Client and ACL stand-ins; real Flowable+ACL+OpenZaak delivery is
the live-stack verify-domain check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:15:23 +02:00
22ab38f328 feat(domain): expose POST /registrations and the read endpoint (refs #6)
The BIG Domain Service Api wires the use cases and the hosted job worker:
POST /registrations creates the aggregate and starts the registratie process,
returning 202 with a location; GET /registrations/{id} reads the aggregate so
the eventually-opened zaak URL can be observed (ADR-0009). The Workflow Client
is registered once behind both Flowable ports; the ACL client and in-memory
store complete the wiring. Verified against a live flowable-rest: submit starts
a parked process and the worker polls it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:13:27 +02:00
0d34d60797 feat(domain): implement the Flowable Workflow Client and ACL client (refs #6)
FlowableWorkflowClient speaks flowable-rest's REST API (Basic auth): start a
registratie process with the registrationId variable, acquire OpenZaakAanmaken
external-worker jobs and parse their registrationId, complete a job with the
zaakUrl variable — the contract verified against a live engine. AclHttpClient
POSTs the bsn to the ACL and returns the zaak URL. InMemoryRegistrationStore is
a concurrent-dictionary upsert. OpenZaakJobProcessor drains parked jobs, opening
a zaak per job and completing it, leaving failures for redelivery; OpenZaakJobPump
is the hosted polling shell that drives it on an interval (ADR-0009).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:10:21 +02:00
6d4adaf957 test(domain): Workflow Client, ACL client, store and job processor (refs #6)
Failing infrastructure unit tests (stub HttpMessageHandler, fakes):
- FlowableWorkflowClient starts a process with the registrationId variable and
  returns the instance id; acquires OpenZaakAanmaken jobs (topic/workerId/lock)
  and parses their registrationId; completes a job with the zaakUrl variable —
  request URIs match flowable-rest's service/ and external-job-api/ paths.
- AclHttpClient POSTs the bsn to the ACL and returns the zaak URL.
- InMemoryRegistrationStore saves/reads/upserts by id.
- OpenZaakJobProcessor acquires, opens a zaak, completes the job; leaves a failing
  job uncompleted for redelivery; polls harmlessly when idle.

Adapters are stubs so the tests compile and fail on their assertions; the green
commit implements them against the REST contract verified on a live Flowable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:08:56 +02:00
39b2388a9d feat(domain): implement SubmitRegistration and OpenZaakWorker (refs #6)
SubmitRegistration creates the aggregate, persists it, starts the registratie
process via the Workflow Client, records the instance id and upserts. OpenZaakWorker
loads the correlated registration, opens a zaak via the ACL, attaches it and saves;
an unknown registration throws (job redelivered), and an already-opened zaak short-
circuits without opening a second one (§8.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:00:05 +02:00
8d176c2603 test(domain): SubmitRegistration + OpenZaakWorker use cases (refs #6)
Failing application-layer tests over fake ports (IWorkflowClient, IAclClient,
IRegistrationStore):
- Submit persists an INGEDIEND registration and starts the registratie process,
  recording the instance id — and persists *before* starting, so the worker can
  correlate the OpenZaakAanmaken job back to its aggregate (ADR-0009).
- The worker opens a zaak via the ACL and attaches it; an unknown registration
  throws (job left for redelivery); a redelivered job is idempotent and opens no
  second zaak (§8.6).

Handlers are stubs (no persistence / no ACL call) so the tests compile and fail
on their assertions; the green commit implements them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:59:27 +02:00
53751fd1bc feat(domain): implement the Registration aggregate invariants (refs #6)
Submit requires a bsn and starts the aggregate in INGEDIEND; the started
process-instance id is recorded; AttachZaak stores the ACL's zaak URL,
tolerating a duplicate (at-least-once worker delivery) and rejecting a
conflicting URL, without advancing the status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:56:31 +02:00
cc9e7852e1 test(domain): Registration aggregate invariants (refs #6)
Failing unit tests for the Registration aggregate root: a submission starts
in INGEDIEND carrying its bsn, an empty/whitespace/null bsn is rejected, the
started process-instance id is remembered, and attaching the zaak the ACL
opened records its URL idempotently (a conflicting URL is rejected) while the
status stays INGEDIEND.

The aggregate is a stub (no-op mutators, empty bsn) so the tests compile and
fail on their assertions; the green commit implements the invariants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:54:59 +02:00
c9edf27a48 arch(domain): ADR-0009 external-task job-worker pattern (refs #6, #60)
The Domain Service drives the OpenZaakAanmaken external-worker task as a
hosted job worker (PRD §36): POST /registrations starts the registratie
process and returns; a polling worker acquires the job, opens a zaak via
the ACL (§8.1), attaches the zaak URL to the aggregate, and completes the
job. The Workflow Client is the only Flowable client (§8.2); the worker
logic is an Application service over ports. Registration state is in-memory
for the minimal slice (the read path is the projection, S-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:51:40 +02:00
c3ccffe417 Merge pull request 'feat(event-subscriber): NRC event subscriber + rebuildable read projection (closes #7)' (#59) from feat/7-event-subscriber-projection into main
Some checks failed
CI / lint (push) Successful in 1m0s
CI / build (push) Successful in 46s
CI / unit (push) Successful in 53s
CI / mutation (push) Failing after 11m57s
CI / verify-stack (push) Successful in 4m57s
Reviewed-on: #59
2026-06-30 13:56:59 +00:00
0d0778036e docs(arch): ADR-0008 read projection store + demo note for the event path (refs #7)
All checks were successful
CI / lint (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 45s
CI / unit (pull_request) Successful in 54s
CI / mutation (pull_request) Successful in 2m16s
CI / verify-stack (pull_request) Successful in 5m34s
ADR-0008 records the read-projection design: one rebuildable store shared by the Event
Subscriber (writer) and projection-api (reader) as one CQRS bounded context (reconciled
with §8.5), idempotency + rebuild from the notification log (no OpenZaak access, §8.1),
the deferred bsn/naam, and the new EF Core + Npgsql dependency. Add a demo-script entry
walking the OZ→NRC→subscriber→projection-api path and wire both into the MkDocs nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:36 +02:00
fa8382fc02 ci(infra): run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
Add projection-db + the two services to both compose files (host ports 8110/8120), their
Dockerfiles (repo-root context — they share Projection.ReadModel), and a runner-safe
verify-projection check (infra/run-projection-check.sh) that registers the abonnement at the
real subscriber, creates a zaak and asserts projection-api serves an INGEDIEND row. Wire it
into make (verify-projection, verify, WAIT_SVCS) and the CI verify-stack job, and run the
event-subscriber Stryker ratchet in `make mutation` + upload its report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:24 +02:00
06d8d13e19 feat(event-subscriber): enforce the callback bearer before reading the body (refs #7)
Check the Authorization header before deserializing the notification, and read/parse the
body manually. NRC probes a new abonnement's callback with a request that has neither the
configured auth nor a valid notification body, and refuses to register unless it gets a 401
(not a 400) — ADR-0007. Mirrors the verify harness's sink contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:14 +02:00
a111e5cc20 test(event-subscriber): ratchet projector mutation baseline to 100% (refs #7)
Sharpen the projector tests so Stryker has no survivors (was 75%): assert a replayed
delivery never reaches the store (upsert count, not just row count), that two distinct
zaken get distinct rows (pins the idempotency key), that rebuild clears stale rows, and
a Theory over wrong kanaal/resource/actie combinations (pins the zaken/zaak/create guard).
Add the per-service Stryker config + solution; break threshold 90 (CLAUDE.md §5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:06 +02:00
7ef63c7ae9 feat(projection): persist the read projection and expose webhook + read APIs (refs #7)
Add the projection persistence and the two services around it:

- Projection.ReadModel: a shared EF Core (Npgsql) read model owning the projection
  schema — register_projection + the subscriber's processed_notifications log — plus
  EfProjectionStore / EfNotificationLog (atomic record-or-skip on the PK for idempotency)
  and the initial migration. One rebuildable store, written by the subscriber and read
  by projection-api (ADR-0008).
- EventSubscriber.Api: POST /notifications NRC callback (enforces the abonnement bearer,
  401 without it per ADR-0007), POST /admin/rebuild, /health. Migrates on start.
- ProjectionApi.Api: GET /register, GET /register/{id}, /health — the read side.

dotnet-ef pinned as a local tool for migrations; NuGetAuditMode=direct so EF's
design-time-only tooling transitive doesn't flag the shipped build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:55:04 +02:00
017cd5e66b feat(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Implement NotificationProjector: a zaken/zaak/create notification records the delivery
in the notification log (atomic record-or-skip for idempotency, §8.6) and upserts an
INGEDIEND projection row keyed by zaak id; other channels/actions are ignored. Rebuild
clears the projection and replays the log — no OpenZaak access needed (§8.1). bsn/naam
are deferred (ADR-0008).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:48:08 +02:00
c70840e5b7 test(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Failing unit + acceptance tests for the Event Subscriber's NotificationProjector:
a zaken/zaak/create notification yields one INGEDIEND projection row, duplicate
deliveries collapse to one row, non-zaak/non-create notifications are ignored, and
a rebuild repopulates the projection from the durable notification log (PRD §8.4).

The projector is a no-op stub so the tests compile and fail on the assertions; the
implementation follows in the green commit. The notification log doubles as the
idempotency guard and rebuild source so a rebuild needs no OpenZaak access (§8.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:47:16 +02:00
32c98f00db Merge pull request 'feat(infra): wire OpenZaak → Open Notificaties notifications (closes #56)' (#57) from feat/56-nrc-notification-wiring into main
All checks were successful
CI / lint (push) Successful in 52s
CI / build (push) Successful in 40s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m35s
CI / verify-stack (push) Successful in 4m44s
Reviewed-on: #57
2026-06-30 12:29:43 +00:00
d49443353e refactor(ci): one verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 48s
CI / mutation (pull_request) Successful in 1m36s
CI / verify-stack (pull_request) Successful in 4m37s
On the single self-hosted runner CI jobs run sequentially, so booting OpenZaak once
beats once-per-job. Replace the integration + notifications + compose-smoke jobs with
one verify-stack job that brings the full stack up once and runs, as clearly-named
steps: health (make verify-up, the DoD smoke) → ACL ↔ OpenZaak (verify-acl) →
OpenZaak → NRC delivery (verify-nrc) → teardown (always) + log dump on failure.

The check logic moves into stack-agnostic runners (run-acl-integration.sh,
run-notification-check.sh) that operate on whatever stack is already up, reaching
services by container IP. The local single-concern wrappers (make integration oz-only,
make verify-notifications oz+nrc) keep working by delegating to the same runners, so
nothing is duplicated. make ci now runs the consolidated 'verify' stage.

Verified locally: make verify boots the full stack once, ACL integration passes and
the NRC notification is delivered, then tears down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:48:38 +02:00
a256db1a23 arch(infra): ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 49s
CI / mutation (pull_request) Successful in 1m35s
CI / integration (pull_request) Successful in 3m24s
CI / notifications (pull_request) Successful in 3m14s
CI / compose-smoke (pull_request) Successful in 4m2s
Records the wiring decision (AC-delegated auth, required celery-beat) and the two
non-obvious gotchas: single-label hosts aren't URL-valid (reach services by IP) and
abonnement callbacks must enforce auth. Documents the new notifications CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
4d07285dcd test(infra): verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
make verify-notifications brings the stack up, seeds a published BIG zaaktype, and
asserts a zaak-create notification is delivered to a webhook-sink abonnement. The
sink + driver run as containers inside the compose network and reach OpenZaak/NRC by
container IP (the runner can't reach published ports, and a single-label host isn't
URL-valid). The sink enforces a bearer token because NRC refuses an unauthenticated
callback. New 'notifications' Gitea Actions job runs it (Docker-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
f3e9db7147 feat(infra): wire OpenZaak → Open Notificaties notifications (refs #56)
Completes the S-01-c wiring so a zaak created in OpenZaak is published to NRC:

- OpenZaak: a zgw_consumers 'nrc' service + notifications_config (setup_configuration),
  publishing as big-reference-seed. NOTIFICATIONS_DISABLED stays true for OpenZaak-only
  bring-ups (OZ_NOTIFICATIONS_DISABLED) so the ACL integration test doesn't 500; the
  full/local stacks and stack-up set it false.
- NRC: the JWT credential, an 'ac' service + autorisaties_api delegation to OpenZaak's
  Autorisaties API, and the 'zaken' kanaal. nrc-init now runs setup_configuration; its
  data.yaml is delivered via the rr-nrc-config volume (seed-config.sh nrc), mirroring oz.
- nrc-beat added to every stack: NRC accepts a notification then drains it via a
  scheduled execute_notifications task — without beat, nothing is delivered. Interval 5s.

Applied across the standalone, full, and local-bind-mount composes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
86cc65f4d9 Merge pull request 'test(acl): ACL integration test against real OpenZaak (closes #46)' (#54) from test/46-acl-openzaak-integration into main
All checks were successful
CI / lint (push) Successful in 50s
CI / mutation (push) Successful in 1m37s
CI / integration (push) Successful in 3m28s
CI / compose-smoke (push) Successful in 3m53s
CI / build (push) Successful in 40s
CI / unit (push) Successful in 48s
Reviewed-on: #54
2026-06-29 10:48:00 +00:00
4474585606 ci(acl): run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Successful in 1m31s
CI / integration (pull_request) Successful in 3m43s
CI / compose-smoke (pull_request) Successful in 4m1s
The hosted runner can't reach the stack's published ports (sibling containers),
so run the seed and the test as containers joined to the OpenZaak network,
reaching it by container IP — a single-label host like 'openzaak' isn't URL-valid
for OpenZaak's own URLValidator, but an IPv4 literal is. Code is delivered via
image build / docker cp (bind mounts don't reach the daemon either).

- infra/run-integration.sh: up -> wait healthy (docker inspect) -> seed published
  zaaktype (python container on the net) -> build + run the test image on the net
  -> always tear down. Plain docker primitives only (portable docker/podman).
- services/acl/Dockerfile.integration: builds + runs Acl.IntegrationTests; dotnet
  lives in the image, so the CI job needs only Docker (no setup-dotnet).
- make integration now delegates to the script; re-added the Gitea Actions job.

Supersedes the local-only gap documented earlier; #55 is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:28:43 +02:00
3829cb0b68 ci(acl): keep the integration lane local-only; document the runner gap (refs #46)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 50s
CI / mutation (pull_request) Successful in 1m31s
CI / compose-smoke (pull_request) Successful in 3m54s
The hosted Gitea runner starts the OpenZaak stack as sibling containers via the
host daemon, so a process on the runner can't reach the published ports — the seed
and dotnet test get Connection refused on localhost:8000. Drop the (non-working)
integration CI job; make integration stays the local / host-runner gate. Document
the limitation in gitea-actions-gotchas.md §5 and the CI runbook, and track running
it inside the compose network in #55. ADR-0006 updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:04:44 +02:00
855a5565fe ci(acl): run the ACL integration test as a Gitea Actions job (refs #46)
Some checks failed
CI / lint (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 46s
CI / mutation (pull_request) Successful in 1m37s
CI / build (pull_request) Successful in 41s
CI / integration (pull_request) Failing after 5m16s
CI / compose-smoke (pull_request) Successful in 4m11s
New integration job: setup-dotnet + make integration (stack up, OZ_PUBLISH=1 seed,
Integration-category tests, tear down), with on-failure log dump + teardown like
compose-smoke. Documents the job and the new make target in the CI runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
09de500fb8 arch(acl): ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
Records why the integration test targets the running compose stack rather than a
Testcontainers graph (no .NET compose support; not hermetic anyway due to the
Selectielijst dependency), the opt-in publish seed, and the chunked-body bug the
test caught. Proposed in #53.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
4322c607cb fix(acl): buffer the zaak POST body so OpenZaak accepts it (refs #46)
OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
JsonContent streams without a Content-Length (Transfer-Encoding: chunked), so
buffer it first. Only a real OpenZaak surfaces this — the integration test from
the previous commit now passes. A unit test asserts a Content-Length is sent
(captured before the stub reads/buffers the body), guarding the fix in the fast
lane and killing the Stryker mutant that would otherwise survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:27 +02:00
d0582cef65 test(acl): integration test opens a real zaak against OpenZaak (refs #46)
S-04a: the deferred S-04 acceptance criterion. A gated Acl.IntegrationTests
project (Category=Integration) drives the real OpenZaakGateway against the
running compose stack — real ZGW JWT auth and the real POST /zaken contract a
stubbed HttpMessageHandler cannot exercise. The lane is kept out of the fast
checks: make unit filters Category!=Integration, Stryker is pinned to Acl.Tests,
and a new make integration target brings the stack up, seeds a published zaaktype
and tears down.

Red: against real OpenZaak the gateway POST fails 400 — JsonContent streams the
body chunked and OpenZaak's uwsgi rejects it. Fixed in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:17 +02:00
f2e575b427 feat(infra): publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
OpenZaak rejects a zaak against a concept zaaktype (not-published). Add an
opt-in OZ_PUBLISH path that creates the relations publish requires — two
statustypen, a roltype, and a resultaattype whose Selectielijst procestype is
matched onto the zaaktype — then publishes. Default stays concept (ADR-0002);
only the ACL integration test flips it on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:05 +02:00
fd5fa5ac3c Merge pull request 'test(acl): ACL mutation-score baseline with Stryker.NET (closes #47)' (#52) from feat/47-acl-mutation-baseline into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build (push) Successful in 41s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m25s
CI / compose-smoke (push) Successful in 4m1s
Reviewed-on: #52
2026-06-29 09:00:29 +00:00
5f3dd31925 fix(ci): pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 46s
CI / unit (pull_request) Successful in 43s
CI / mutation (pull_request) Successful in 1m49s
CI / compose-smoke (pull_request) Successful in 4m2s
The artifact step failed the mutation job: upload-artifact@v4 bundles
@actions/artifact v2, which hard-aborts on any non-github.com server ("not
supported on GHES"), even though Gitea 1.25 stores artifacts fine. @v3 uses the
older protocol Gitea speaks and has no GHES guard — a drop-in swap (same inputs).
Document it as gotcha §4 and correct the CI runbook note.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:28:07 +02:00
347713766e ci(acl): publish the Stryker HTML report as a CI artifact (refs #47)
Some checks failed
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 41s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Failing after 1m50s
CI / compose-smoke (pull_request) Successful in 3m55s
Add an upload-artifact step to the mutation job so the ACL mutation report is
downloadable from the run summary. `if: always()` uploads it even when the
ratchet fails — exactly when the survivors matter. A glob handles Stryker's
timestamped output directory. First use of actions/upload-artifact (@v4, pinned);
Gitea 1.25.x supports it. Document it in the CI runbook.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:21:26 +02:00
7ecc184111 arch(acl): ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 45s
CI / mutation (pull_request) Successful in 1m24s
CI / compose-smoke (pull_request) Successful in 4m1s
Record the decision to adopt Stryker.NET (pinned local tool, solution mode on
Acl.slnx) and to set the first repo-wide mutation baseline on the ACL: observed
95%, enforced break threshold 90%. Document the ratchet, local run, and report
location in the CI runbook; add the ADR to the docs nav.

Proposed in #51 (adr-proposal). Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:59:41 +02:00
e8510bf9c3 ci(acl): run the mutation ratchet as a parallel CI job (refs #47)
Add a `mutation` job mirroring the unit job (checkout + pinned setup-dotnet,
then `make mutation`). It runs in parallel with lint/build/unit/compose-smoke
and gates merges on the ACL mutation baseline (CLAUDE.md §5/§15). The job calls
the same make target developers run, so the pipeline stays a mirror of `make ci`.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:22 +02:00
6ac2fca384 test(acl): add Stryker config + mutation make target recording the 95% baseline (refs #47)
Configure Stryker.NET for the ACL in solution mode (Acl.slnx), so both
Acl.Application and Acl.Infrastructure — the two projects under test — are
mutated while Acl.Api (untested) is skipped. Record the repo-wide mutation
baseline as the ratchet (CLAUDE.md §5): observed score 95%, enforced break
threshold 90% (one-mutant headroom over the ~20-mutant surface). The ACL is the
first service with branching logic, so it sets the baseline; later slices
ratchet it up deliberately, never down.

Add a `mutation` make target (`dotnet tool restore` + `dotnet stryker`) and wire
it into the `make ci` aggregate, keeping `make ci` an exact mirror of the
pipeline.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:00 +02:00
10816f5303 test(acl): kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
Stryker exposed thin ACL tests (35% mutation score): the suite never asserted
the geo CRS headers, the ArgumentNullException guards, the non-success and
empty-body error paths, or the structure of the minted ZGW JWT — so mutating
any of those survived.

Strengthen the unit tests to kill those mutants:
- assert Accept-Crs / Content-Crs are EPSG:4326,
- assert OpenZaakAsync rejects a null request/registration without calling out,
- assert a non-2xx response throws and an empty body throws InvalidOperationException,
- decode the Bearer token and assert the HS256 header + acl identity claims.

Raises the ACL mutation score to 95%. The one remaining survivor mutates only
the exception *message* text (an equivalent mutant — message strings are not
worth a brittle assertion).

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:51 +02:00
89b097d015 build(acl): pin Stryker.NET as a local dotnet tool (refs #47)
Add a tool manifest pinning dotnet-stryker 4.15.0 so `make mutation` runs
the same mutation tester locally and in CI from a fresh clone (`dotnet tool
restore`), with no global install. Ignore the generated StrykerOutput/ report
directory.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:38 +02:00
5a83216395 Merge pull request 'ci(infra): Gitea Actions CI pipeline + full-stack compose smoke (closes #30)' (#50) from ci/30-gitea-actions-ci into main
All checks were successful
CI / lint (push) Successful in 49s
CI / build (push) Successful in 43s
CI / unit (push) Successful in 46s
CI / compose-smoke (push) Successful in 3m54s
Reviewed-on: #50
2026-06-25 12:34:43 +00:00
200 changed files with 26605 additions and 89 deletions

20
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,20 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
# .NET sources use 4-space indent (dotnet format enforces this). The 2-space default
# above is for the frontend (TS/HTML/CSS/JSON); C# keeps the .NET convention.
[*.cs]
indent_size = 4
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -23,6 +23,16 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
# — a miss just restores from the network. See issue #73.
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make lint
build:
@@ -32,6 +42,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make build
unit:
@@ -41,16 +57,107 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
compose-smoke:
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- run: make smoke
- name: dump container logs on failure
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11
- uses: https://github.com/actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- run: make frontend
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: event-subscriber-mutation-report
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: domain-mutation-report
path: services/domain/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
continue-on-error: true
with:
name: bff-mutation-report
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
verify-stack:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health
run: make verify-up
- name: ACL ↔ OpenZaak integration tests
run: make verify-acl
- name: OpenZaak → NRC notification delivery
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
run: make verify-projection
- name: Domain → Flowable → ACL → OpenZaak
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()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=80 oz-init openzaak nrc-init nrc-web flowable-init keycloak acl bff 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/docker-compose.yml down --volumes 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

25
.gitignore vendored
View File

@@ -15,6 +15,9 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/
@@ -32,3 +35,25 @@ site/
# OS
.DS_Store
Thumbs.db
# ── Frontend (Nx / Angular / pnpm) ──
node_modules/
dist/
tmp/
out-tsc/
/coverage
.angular/
.nx/cache
.nx/workspace-data
.nx/self-healing
.nx/migrate-runs
.nx/polygraph
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/

8
.prettierignore Normal file
View File

@@ -0,0 +1,8 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular
.nx/self-healing

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -151,17 +151,16 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
### S-08 · Self-Service portal (Angular, NL DS) — submit a registration
**Outcome:** The self-service Angular app, in the Nx monorepo, lets a zorgprofessional log in via mock DigiD and submit a registration. NL Design System styling. Generated API client.
> **S-08 was split** (CLAUDE.md §13; issue #9 closed) into the sub-slices below — it bundled the
> Nx bootstrap, the generated client, the NL DS + DigiD form, and a full-stack Playwright e2e, well
> past 12 days. Each sub-slice is independently demoable and CI-green.
**Acceptance:**
- **S-08a (#65)** · Nx monorepo + Angular tooling + CI Node lane. Placeholder `self-service` app; `nx lint/test/build` green in a new CI Node lane.
- **S-08b (#66)** · Generated api-client lib from `services/bff/openapi.json` (never hand-written, §10) + a mocked-BFF unit test.
- **S-08c (#67)** · Self-service submit form — NL Design System `libs/ui`, DigiD OIDC `libs/auth`, component tests (Angular Testing Library), axe WCAG 2.1 AA on the submit page.
- **S-08d (#68)** · Playwright happy-path e2e (login → submit → success) against the full stack + compose serving + CI e2e lane.
- E2E test (Playwright): full happy path, login → submit → success page.
- Component tests (Testing Library) for the form.
- Accessibility audit (axe-core) passes WCAG 2.1 AA on the submit page.
**Touches:** `apps/self-service/`, `libs/ui/`, `libs/auth/`, `libs/api-client/`, tests.
**Out of scope:** document upload, status tracking page.
**Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup

123
Makefile
View File

@@ -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
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
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-kc-realms rr-fl-bpmn
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,10 +43,23 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
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
pnpm nx run-many -t build
## lint: verify formatting (no changes)
lint:
@@ -56,9 +69,21 @@ lint:
build:
dotnet build $(SLN) -c Release
## unit: run unit tests
## unit: run unit tests (excludes the container-backed Integration lane)
unit:
dotnet test $(SLN) -c Release
dotnet test $(SLN) -c Release --filter "Category!=Integration"
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
# Scores never regress below baseline.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker
cd services/domain && dotnet stryker
cd services/bff && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
@@ -68,14 +93,14 @@ unit:
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
$(SEED) oz kc fl
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz kc fl
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -97,6 +122,72 @@ local-down:
changelog:
git-cliff --output CHANGELOG.md
# ── ZGW verification ───────────────────────────────────────────────────────
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
# check logic lives in stack-agnostic runners that reach services by container IP
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
# convenience wrappers that bring up a lighter stack and call the same runners.
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
verify-acl:
bash infra/run-acl-integration.sh
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
verify-nrc:
bash infra/run-notification-check.sh
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
## against the already-running stack.
verify-projection:
bash infra/run-projection-check.sh
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
verify-domain:
bash infra/run-domain-check.sh
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
## + anonymous public-safe openbaar register (ADR-0010).
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.
verify:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \
&& 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'
## integration: local convenience — ACL integration test against a throwaway
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up:
$(SEED) oz
@@ -129,10 +220,16 @@ openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config
## stack-up: start OpenZaak + Open Notificaties together (shared network)
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
verify-notifications:
bash infra/verify-notifications.sh
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
## OpenZaak publishing notifications to NRC (S-01-c).
stack-up:
$(SEED) oz
docker compose $(STACK_FILES) up -d
$(SEED) oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up
@@ -151,7 +248,7 @@ stack-smoke: stack-up
## stack-down: stop and remove both stacks (wipes data)
stack-down:
docker compose $(STACK_FILES) down --volumes
-docker volume rm -f rr-oz-config
-docker volume rm -f rr-oz-config rr-nrc-config
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:

View File

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

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -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;
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "self-service",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/self-service/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/self-service",
"browser": "apps/self-service/src/main.ts",
"tsConfig": "apps/self-service/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/self-service/public"
}
],
"styles": ["apps/self-service/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "self-service:build:production"
},
"development": {
"buildTarget": "self-service:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "self-service:build",
"staticFilePath": "dist/apps/self-service/browser",
"spa": true
}
}
}
}

View File

@@ -0,0 +1,3 @@
{
"authority": "http://localhost:8180/realms/digid"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,65 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
// and the token storage are faked, so the assertion turns on the actual route-matching.
describe('self-service DigiD token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'digid-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative self-service BFF call', () => {
bff.postSelfServiceRegistrations().subscribe();
const req = http.expectOne('/self-service/registrations');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});

View File

@@ -0,0 +1,42 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes';
/** 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;
}
/**
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
* which stays relative, so an absolute origin would never match and the token would go unattached.
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
*/
export const SECURE_API_ROUTES = ['/self-service/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
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,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,7 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { RegistrationPage } from './registration/registration-page';
export const appRoutes: Route[] = [
{ path: '', component: RegistrationPage, canActivate: [authenticatedGuard] },
];

View File

@@ -0,0 +1,15 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegistrationPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'self-service';
}

View File

@@ -0,0 +1,27 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {
<p utrecht-paragraph role="alert">
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="submitting()"
(click)="submit()"
>
Registratie indienen
</button>
}
</utrecht-article>
</main>

View File

@@ -0,0 +1,71 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe';
import { RegistrationPage } from './registration-page';
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>('123456782');
login(): void {
/* noop */
}
logout(): void {
/* noop */
}
}
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
return {
post,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
],
};
}
describe('RegistrationPage', () => {
it('shows the signed-in BSN', async () => {
await render(RegistrationPage, { providers: providers().providers });
expect(screen.getByText(/123456782/)).toBeTruthy();
});
it('submits the registration and confirms', async () => {
const { post, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
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.
document.documentElement.lang = 'nl';
const { container } = await render(RegistrationPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
*/
@Component({
selector: 'app-registration-page',
imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html',
})
export class RegistrationPage {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
protected readonly bsn = this.auth.bsn;
protected readonly submitting = signal(false);
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
submit(): void {
this.submitting.set(true);
this.failed.set(false);
this.bff.postSelfServiceRegistrations().subscribe({
next: (accepted: SubmitAccepted) => {
this.reference.set(accepted.registrationId);
this.submitted.set(true);
this.submitting.set(false);
},
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
error: () => {
this.failed.set(true);
this.submitting.set(false);
},
});
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>self-service</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// 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<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));

View File

@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,68 @@
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** Respellion engineering
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
## Context
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
strictly, no slice could satisfy that gate. S-04b establishes it.
The ACL is the natural place to set the first baseline: it is the first service with real
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
that:
- mutates C# and runs the existing xUnit suite per mutant,
- is reproducible (same version locally and in CI, no global install),
- understands this repo's `.slnx` solution format (used repo-wide),
- emits a break threshold CI can gate on.
## Decision
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
- **Solution mode** (`stryker-config.json``solution: Acl.slnx`) mutates the two projects
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%**
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
## Consequences
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
the small ACL). Pinned + tool-restored, so reproducible.
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
Asserting exception message text is brittle and the behaviour (type + control flow) is
unchanged — treated as an equivalent mutant, not a test gap.
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
services add their own mutation run as they gain branching logic (BFF, Domain, …).
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
names; the fallback is no mutation testing, which §5 forbids.
## Alternatives considered
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
local manifest gives every checkout and the CI runner the same version.
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
opts in as it gains logic.
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
the riskiest code — unguarded by the ratchet.
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
tests would *catch* a regression; that is the whole point of §5.

View File

@@ -0,0 +1,92 @@
# ADR-0006: Provision the ACL integration test against the compose stack
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
## Context
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
> Integration test using Testcontainers against real OpenZaak passes.
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
Investigation reversed the initially-favoured Testcontainers option:
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
mean re-implementing that five-service stack — init ordering, the config volume, health
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
which OpenZaak validates by fetching the external **Selectielijst** reference API
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
regardless of how the containers are started.
## Decision
**The ACL integration test targets the running compose stack; it does not start containers
itself. No new test dependency is added.**
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
`OpenZaakGateway` against it.
- **The lane is kept out of the fast checks.** `make unit` runs with
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
neither the unit nor the mutation lane needs a live stack. A `make integration` target
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
source of truth (ADR-0005).
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
a concept; only `make integration` flips the switch.
## Consequences
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
test asserting a `Content-Length` is set. This is the concrete justification for §11's
integration tier.
- **External dependency:** the integration job needs the OpenZaak container to reach
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
in production) but it is a network touchpoint, and a CI environment without egress would need
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
## Alternatives considered
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
undermine the contract the test exists to verify.
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
tests already do; it cannot exercise the real contract, and would not have caught the chunked
body bug.

View File

@@ -0,0 +1,77 @@
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
## Context
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
subscribers — before the Event Subscriber (#7) can consume it.
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
were nailed down by iterating `setup_configuration` against the running stack.
## Decision
**Provision both sides declaratively via `setup_configuration`, authenticate with the
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present**
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
so they don't 500 publishing to an absent NRC.
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
daemon).
- **celery-beat is required.** NRC accepts a notification and writes a
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
S-01 stack dropped beat — so notifications were accepted but never delivered. An
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
asserts the sink receives the `zaken`/`create` notification — all from containers
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
`verify-stack` job (one shared full-stack bring-up — issue #58).
## Consequences
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
notifications; the wiring is declarative and reproducible from a fresh `make`.
- **Gotchas captured (see gitea-actions-gotchas.md):**
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
harness reaches services and registers the sink callback **by container IP**.
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
without the configured `Authorization`; the sink enforces a bearer token.
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
needs egress (base images + `selectielijst.openzaak.nl`, since the published
zaaktype the check creates a zaak against depends on it — ADR-0006).
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
AC lookup, and seeding — acceptable for the reference app, not production.
## Alternatives considered
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
Autorisaties API is the upstream-intended model and reuses the applicatie that
already grants `heeft_alle_autorisaties`.
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
delivers via scheduled notifications drained by beat; there is no sync path.
- **A persistent abonnement in `setup_configuration`** instead of registering one in
the verify harness — deferred: the real subscriber is #7; the harness's sink
abonnement is throwaway and IP-specific.

View File

@@ -0,0 +1,89 @@
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
- **Status:** Accepted
- **Date:** 2026-06-30
- **Deciders:** Respellion engineering
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
## Context
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status`
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
(a derived artefact, never a write-only source of truth).
Two design questions had no obvious answer:
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
not the ACL.
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
schema." Two deployables on one table looks like a violation.
## Decision
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
deferred.**
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
holds for every domain database.
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
derived from the immutable notification content). A duplicate insert raises a unique violation,
caught and reported as "already recorded", so the duplicate never reaches the projection. The
projection upsert is itself idempotent on the zaak id, a second line of defence.
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
is in place so that change is additive.
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
audited, shipped graph.
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
in-network, reaching services by container IP (ADR-0006/0007).
## Consequences
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
ready for S-09 to tighten public-safe field filtering.
- **Negative / deferred:**
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
- The abonnement is registered by the verify harness (by container IP), not provisioned
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
path flows.
- Two services share one database. Acceptable for a derived read model; revisit if the read and
write sides ever need independent scaling or storage.
## Alternatives considered
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
to ZGW) and would need its own ADR to bend the rule.
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
deferred to a follow-up.
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
current benefit over a shared, rebuildable read model.
- **Separate databases for the log and the projection** — rejected as premature: both are the read
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.

View File

@@ -0,0 +1,88 @@
# ADR-0009: The Domain Service drives Flowable as an external-task job worker
- **Status:** Accepted
- **Date:** 2026-06-30
- **Deciders:** Respellion engineering
- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation)
## Context
S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a
`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the
`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL
back on the aggregate.
`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`,
topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a
worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain
who may do what:
- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed
OpenZaak knowledge; they ask the Workflow Client to execute external tasks.
- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*,
never by constructing ZGW URLs itself.
This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are
exercised. The open question is *how* the external task is driven.
## Decision
**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker
(PRD §36). Orchestration is eventually consistent, not request-synchronous.**
- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the
`Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start
one `registratie` process instance, recording the process-instance id on the aggregate. It returns
immediately; it does **not** wait for the zaak to be opened.
- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls
the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate
(`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end
event.
- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's
`Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete
external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable
exists.
- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker`
takes an acquired job (topic + the registration id it carries), calls `IAclClient` and
`IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin
`BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to
the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing
needs a container integration test.
## Scope decisions for the minimal slice
- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by
NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain
store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore`
port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.)
- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal,
beoordeling, herregistratie) add states in their own slices — they are out of scope here.
- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory
fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged.
## Consequences
- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common
Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the
Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable.
- **Negative / deferred:**
- Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet.
Acceptable — the read side is the projection, not the domain store.
- In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store
in a follow-up.
- The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since
Flowable holds the job until completed.
## Alternatives considered
- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and
deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the
external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is
briefly down, instead of the job simply staying parked for the worker to retry.
- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this
slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places
the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a
shared store for no current benefit.
- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker
model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker
delivery guarantees.

View File

@@ -0,0 +1,74 @@
# ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend
- **Status:** Accepted
- **Date:** 2026-07-01
- **Deciders:** Respellion engineering
- **Relates to:** S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)
## Context
S-07 (#8) adds the **BFF (Backend-for-Frontend)** — the single backend the Angular portals talk
to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services
already built:
- `POST /self-service/registrations` → Domain Service `POST /registrations` (S-05).
- `GET /openbaar/register?q=…` → projection-api `GET /register` (S-06).
It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new
dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).
## Decision
**The BFF is the portals' only backend; it validates Keycloak `digid`-realm JWTs on the
self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and
projection over typed HTTP clients.**
- **Auth model.** `POST /self-service/registrations` requires a valid `digid`-realm bearer token;
the BFF reads the `bsn` claim and forwards it to the domain. Missing / invalid / expired token →
**401**. `GET /openbaar/register` is **anonymous** — the openbaar register is a public lookup
(S-09), so no token is required.
- **Portals talk only to the BFF (§8.3).** They never call the Domain Service, ACL, projection, or
OpenZaak directly. The BFF orchestrates via typed `HttpClient`s whose base URLs come from config.
Downstream calls are unauthenticated on the internal network for the walking skeleton; a
service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
- **Validation is `Microsoft.AspNetCore.Authentication.JwtBearer`** pointed at the Keycloak `digid`
realm authority. **New dependency justification:** it gives us standards-based OIDC/JWT validation
(signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation
would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
- **Tests mint their own tokens.** `WebApplicationFactory` tests override the bearer options with a
**test signing key**, so valid / invalid / expired tokens are minted in-process without a live
Keycloak. Real Keycloak validation is exercised by a live-stack `verify-bff` check.
- **OpenAPI is generated and committed** (`services/bff/openapi.json`) from .NET's built-in OpenAPI,
so S-08's Angular client is generated from the spec, never hand-written (§10).
## Known wrinkle — container OIDC issuer mismatch
Keycloak stamps tokens with an `iss` equal to its **browser-facing** URL (what the portal used to
log in), which differs from the BFF's **in-container** authority (`http://keycloak:8080/realms/digid`).
Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key).
`verify-bff` handles it by aligning the configured authority/issuer with the token's `iss` (and, if
needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.
## Consequences
- **Positive:** the walking skeleton gains its front door; §8.3 holds with all portal traffic going
through one backend; token validation is standard and testable without infra; the committed
OpenAPI unblocks S-08.
- **Negative / deferred:**
- Downstream service-to-service auth is deferred (internal-network trust for now).
- The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays
anonymous but the projection query narrows.
- The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the
browser and internal issuer URLs instead.
## Alternatives considered
- **Token-gate the openbaar endpoint too** — rejected: the openbaar register is public by design
(S-09); requiring a login would contradict the slice's intent.
- **Validate tokens by calling Keycloak's introspection endpoint per request** — rejected: adds a
network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via
the realm's JWKS is the standard, faster choice.
- **Hand-written JWT parsing** — rejected: security-sensitive code we shouldn't own when a
first-party validator exists.
- **Generate the OpenAPI client by hand / keep the spec uncommitted** — rejected: §10 requires a
generated client from a committed spec.

170
docs/demo-script.md Normal file
View File

@@ -0,0 +1,170 @@
# Demo script
A running log of demoable outcomes, one section per slice. Each entry is a short,
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
self-service portal (NL Design System styling); the page confirms with the reference returned by the
BFF. The bsn comes from the DigiD token, so it's a confirm-and-submit flow (no bsn field).
```bash
# 1. Bring the backend + Keycloak up (BFF on :8080, Keycloak on :8180).
make up
# 2. Serve the portal (dev server); it redirects to Keycloak for DigiD login.
pnpm nx serve self-service # → http://localhost:4200
# 3. In the browser: log in as the mock DigiD user jan-burger / test123, then submit.
# The page shows the returned registration reference.
```
> First real UI. The full **login → submit → success** happy path is automated in **S-08d**
> (Playwright, against the compose-served app). Component tests + an axe WCAG 2.1 AA check on the
> submit page run headless in the `frontend` CI lane. See `docs/frontend-decisions.md`.
---
## S-08a — Nx workspace + self-service portal skeleton
**Outcome:** the frontend foundation — an Nx (pnpm) monorepo with the `self-service` Angular app
(standalone + signals), lint/test/build green in a CI Node lane. The login + submit form follow in
S-08c.
```bash
# From a fresh clone (Node 24 + pnpm 11):
pnpm install # native builds are pre-approved in pnpm-workspace.yaml
pnpm nx test self-service # Vitest component test
pnpm nx build self-service # production build
pnpm nx serve self-service # → http://localhost:4200 (placeholder page)
# Or the CI-equivalent one-shot:
make frontend # install + nx lint/test/build
```
> Nx manages only `apps/`+`libs/`; the .NET services stay on `dotnet`/the Makefile. NL Design System
> and the real form arrive in S-08c (#67); see `docs/frontend-decisions.md`.
---
## S-07 — BFF: the portals' single backend
**Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the
bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the
front door the portals (S-08/S-09) will talk to.
**The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and
BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010.
```bash
# 1. Bring the full stack up.
make up
# 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar).
make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..."
# 3. Try it by hand (BFF on host port 8080).
# a) A digid access token for the mock user jan-burger (bsn 123456782):
tok=$(curl -s -X POST http://localhost:8180/realms/digid/protocol/openid-connect/token \
-d grant_type=password -d client_id=big-portal -d username=jan-burger -d password=test123 \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# b) Submit — without the token it is 401; with it, 202:
curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations
curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \
-H "Authorization: Bearer $tok"
# c) The openbaar register is anonymous and exposes only id + status (never the bsn):
curl -fsS http://localhost:8080/openbaar/register | jq
```
> The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no
> token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built
> from it.
---
## S-05 — BIG Domain Service: submit a registration
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton
that produces the zaak S-06 then projects.
**The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken`
worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009).
```bash
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
make up
# 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the
# ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up).
make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration"
# 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back.
loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p')
# 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll
# until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.)
curl -fsS "http://localhost:8130$loc" | jq
# → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/<uuid>" }
```
> Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the
> projection (S-06), fed by the very zaak this flow opens.
---
## S-06 — Event Subscriber + read projection
**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which
projects it into a rebuildable read projection the projection-api serves.
**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber →
`register_projection` → projection-api `GET /register`.
```bash
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
make up
# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back.
# (The verify-projection check does exactly this end-to-end and asserts the result.)
make verify-projection # → "OK — projection-api serves zaak <uuid> with status INGEDIEND"
# 3. Observe the projection directly via the read API (host port 8120).
curl -fsS http://localhost:8120/register | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ]
# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the
# notification log (no OpenZaak access needed — ADR-0008).
curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110
curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
```
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.

105
docs/frontend-decisions.md Normal file
View File

@@ -0,0 +1,105 @@
# 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 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, 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 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 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.

View File

@@ -15,14 +15,23 @@ and CI cannot drift:
|---|---|---|
| `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK |
| `build` | `make build``dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit``dotnet test … -c Release` | .NET 10 SDK |
| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 |
| `unit` | `make unit``dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
> cheapest layout (issue #58). It subsumes the old `integration`, `notifications`, and
> `compose-smoke` jobs — the bring-up step *is* the "compose up reaches green health"
> gate. No `setup-dotnet`: the ACL test runs in a built image and every check reaches
> services by **container IP** (the runner can't reach published ports — see
> [gitea-actions-gotchas.md §5/§6](gitea-actions-gotchas.md)).
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub.
> **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do
> **`verify-stack` runs on a containerized runner.** Workspace bind mounts do
> **not** reach the sibling containers Compose starts, so config/assets are
> streamed into external named volumes via `docker cp` (`infra/seed-config.sh`),
> and the upstream images are used verbatim (no build). If you add a service that
@@ -30,6 +39,38 @@ Actions resolves them from GitHub.
> bare `docker compose up` no longer self-seeds; use `make up`. See
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md).
## Mutation testing (the ratchet)
The `mutation` job enforces test *strength*, not just coverage (CLAUDE.md §5).
[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) is pinned as a local
dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in CI:
```bash
make mutation # dotnet tool restore + dotnet stryker on the ACL
```
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
(one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the
job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only
as a slice's stated outcome; never lower it. New services add their own mutation run as
they gain logic.
The HTML report is written to `services/acl/StrykerOutput/<timestamp>/reports/` (git-ignored);
open it to see survived vs. killed mutants.
In CI the `mutation` job publishes that report as the **`acl-mutation-report`** artifact
(download it from the run's summary page). The upload step uses `if: always()`, so the
report is available even when the ratchet *fails* — which is exactly when you want to inspect
the survivors. It is the repo's first use of `actions/upload-artifact`, pinned to **`@v3`**:
`@v4` refuses to run on Gitea (its `@actions/artifact` v2 library blocks any non-github.com
server as "GHES"), while `@v3` speaks the artifact protocol Gitea implements. See
[gitea-actions-gotchas.md §4](gitea-actions-gotchas.md) (§15).
## Running the stack locally without `make` (Windows / Docker Desktop)
`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
@@ -52,14 +93,24 @@ the containerized CI runner). Keep the two files in sync.
## Running CI locally (`make ci`)
Until the runner exists, run the full pipeline yourself before pushing:
`make ci` runs the exact same checks as the pipeline — handy to run before pushing:
```bash
make ci # lint + build + unit + smoke — what the pipeline runs
make ci # lint + build + unit + mutation + verify — mirrors the pipeline
make lint # or a single stage
make smoke # compose up --wait, curl /health, tear down
make mutation # Stryker.NET ratchet on the ACL
make verify # the live-stack stage: full stack up once → ACL + NRC checks → down
```
> **`make verify`** mirrors the CI `verify-stack` job: it boots the full stack once and
> runs both the ACL ↔ OpenZaak and OpenZaak → NRC checks against it. For fast,
> single-concern local iteration use a lighter throwaway stack instead:
>
> ```bash
> make integration # ACL ↔ OpenZaak only (no NRC)
> make verify-notifications # OpenZaak → NRC delivery only
> ```
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`.
On a **rootless Podman** box (the default dev setup here), the `smoke` target needs

View File

@@ -13,6 +13,8 @@ those containers share a filesystem — or a `localhost` — breaks.
| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` |
| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` |
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
| `upload-artifact@v3` fails with "Artifact service responded with 500" | mark the upload `continue-on-error: true` (server-side; issue #62) | `.gitea/workflows/ci.yaml` (`mutation` job) |
---
@@ -98,3 +100,99 @@ plus app start.
container that starts migrating in that window can fail on a missing PostGIS. So
the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
for the extension, not just the port.
---
## 4. `actions/upload-artifact@v4` refuses to run on Gitea
**Symptom** — the `mutation` job's `make mutation` step passes (95% score), but the
upload step right after it fails the job:
```
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
❌ Failure - Main https://github.com/actions/upload-artifact@v4
```
**Why**`upload-artifact@v4` bundles `@actions/artifact` v2, which inspects the
server URL and **hard-aborts on anything that isn't `github.com`**, treating Gitea
as an unsupported GitHub Enterprise Server. The check fires regardless of whether
the Gitea server can actually store artifacts (1.24+ can). It is the *action*, not
the server, that refuses.
**Fix** — pin **`actions/upload-artifact@v3`** (and `download-artifact@v3` if ever
needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in
swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
artifact support.
**Second failure mode — the server's artifact backend returns 500.** Even on the
correctly-pinned `@v3`, uploads can fail with:
```
Create Artifact Container - Attempt 5 of 5 failed with error: Artifact service responded with 500
::error::Create Artifact Container failed: Artifact service responded with 500
```
This is the **Gitea server's** artifact storage failing (not the action's GHES guard),
so it is outside the repo's control. Because the `mutation` job's upload steps run with
`if: always()`, that 500 would fail the job even though the ratchet passed. **Fix:** mark
the uploads `continue-on-error: true` (issue #62). The mutation *gate* is the Stryker
ratchet — `make mutation`'s exit code fails the job on a real regression — so the report
upload is best-effort: when the server's artifact storage is restored, reports publish
again with no workflow change.
---
## 5. A runner process can't reach a service container's published port
**Symptom** — green locally, but a CI step that runs *on the runner* and talks to a
compose service over `localhost` fails. The ACL integration test's seed died with:
```
OpenZaak ready (000)
urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
make: *** [Makefile:114: integration] Error 1
```
OpenZaak was demonstrably up — uwsgi had been serving for ~2 minutes — yet
`curl`/`urllib` to `localhost:8000` from the runner were refused the whole time.
**Why** — the same sibling-container split as §1. Compose starts the stack via the
host daemon, so `ports: ["8000:8000"]` publishes to the *daemon host*, not to the job
container. From the runner, `localhost:8000` has nothing listening. (`make smoke`
sidesteps this by polling readiness via `docker inspect` (§2), never a service port.)
**Fix** — don't talk to service ports from the runner. Either check state via `docker
inspect` (health), or run the client **inside the compose network** so it reaches the
service by name (`http://openzaak:8000`). For a test/seed that needs the repo's own
code, deliver it via a **built image** (not a bind mount — §1), then
`docker run --network <stack>_cg …`.
**Applied**`make integration` (ADR-0006) and `make verify-notifications` (ADR-0007)
do exactly this: they run the seed/test/driver as containers on the stack network and
reach services by **container IP** (see §6).
---
## 6. OpenZaak / NRC reject single-label hosts in URLs
**Symptom** — talking to OpenZaak or NRC by compose **service name** fails where a URL
is validated: catalogus/zaaktype filters, the zaak `zaaktype` URL, and abonnement
`callbackUrl` come back `400 "Voer een geldige URL in."` — even though the host
resolves and is reachable.
**Why** — these apps validate URLs with Django's `URLValidator`, which rejects a
**single-label** host like `openzaak` or `nrc-web` (no dot, and not `localhost`).
`localhost` passes (so it's invisible in host-port-based local runs); in-network the
reality is a service name or an IPv4 literal — and only the IP passes.
**Fix** — in-network tooling reaches OpenZaak/NRC by **container IP**
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`), not
service name; the notif verify harness also registers the sink callback by IP.
(`infra/run-acl-integration.sh`, `infra/run-notification-check.sh`.)
**Related — abonnement callbacks must enforce auth.** NRC probes a callback when an
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
**401** without the configured `Authorization`. The verify sink
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.

48
eslint.config.mjs Normal file
View File

@@ -0,0 +1,48 @@
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
ignores: [
'**/dist',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
// Permissive default — apps/libs are untagged for now. Introduce scope/type tags
// when the portal set grows (docs/frontend-decisions.md).
depConstraints: [
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
},
],
},
],
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
// Override or add rules here
rules: {},
},
];

View File

@@ -59,7 +59,8 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
# Publish notifications to NRC (always present in this twin). See ADR-0007.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -122,7 +123,9 @@ services:
networks: [cg]
nrc-init:
# Migrations only (see the canonical compose / ADR-0002); no config needed.
# Migrations + setup_configuration (S-01-c): the JWT credential, Autorisaties-API
# delegation, and the `zaken` kanaal that let OpenZaak publish. Config is
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker
@@ -141,7 +144,11 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
NOTIFICATION_SEC_INTERVAL: "5"
command: /setup_configuration.sh
volumes:
- ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z
depends_on:
nrc-db:
condition: service_healthy
@@ -176,6 +183,17 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat drains scheduled notifications to subscribers — required for
# delivery, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
@@ -287,10 +305,68 @@ services:
start_period: 10s
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
projection-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
volumes:
- projection-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
networks:
cg:

View File

@@ -9,6 +9,8 @@
# 8080 BFF GET /health → Healthy
# 8090 Flowable REST http://localhost:8090/flowable-rest/service/
# 8100 ACL GET /health → Healthy POST /zaken
# 8110 Event Subscriber GET /health → Healthy POST /notifications
# 8120 projection-api GET /health → Healthy GET /register
# 8180 Keycloak (admin: admin / admin)
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
@@ -62,7 +64,10 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
# Publish notifications to NRC (always present in this full stack). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/openzaak/setup_configuration/data.yaml). See ADR-0007 / S-01-c.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -146,11 +151,17 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
# nrc-beat fires `execute_notifications` this often to drain scheduled
# notifications to subscribers (upstream default 20s). See ADR-0007.
NOTIFICATION_SEC_INTERVAL: "5"
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish.
# data.yaml is streamed into rr-nrc-config by infra/seed-config.sh (bind mounts
# don't reach sibling containers on the CI runner). See data.yaml + ADR-0007.
command: /setup_configuration.sh
volumes:
- nrc-config:/app/setup_configuration:ro
depends_on:
nrc-db:
condition: service_healthy
@@ -185,6 +196,18 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat drains the ScheduledNotification rows the API creates on publish
# and hands them to the worker. Without it, notifications are accepted but never
# delivered to subscribers — required, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
@@ -262,7 +285,9 @@ services:
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
@@ -283,12 +308,49 @@ services:
condition: service_healthy
networks: [cg]
# ── BIG Domain Service (S-05) ──────────────────────────────────────────────
# Orchestrates a registration: POST /registrations creates the aggregate and
# starts the registratie Flowable process; a hosted worker acquires the
# OpenZaakAanmaken job, opens a zaak via the ACL and completes it (ADR-0009).
# Talks only to Flowable (Workflow Client, §8.2) and the ACL (§8.1).
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
bff:
build:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports:
- "8080:8080"
healthcheck:
@@ -297,18 +359,119 @@ services:
timeout: 3s
retries: 5
start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
# One Postgres DB backing the rebuildable read projection (PRD §8.4): the Event
# Subscriber writes it, projection-api reads it. See ADR-0008.
projection-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
volumes:
- projection-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
# Consumes NRC notifications (abonnement callback) and projects zaak-created events
# into register_projection. Build context is the repo root: it shares the read model
# in services/projection-api/Projection.ReadModel.
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
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:
# 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
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
oz-config:
external: true
name: rr-oz-config
nrc-config:
external: true
name: rr-nrc-config
kc-realms:
external: true
name: rr-kc-realms

39
infra/notification-sink.py Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""A throwaway webhook sink for verifying the OpenZaak → NRC notification path.
NRC delivers abonnement callbacks here as POSTs; each body is printed to stdout
(prefixed `NOTIFICATION `) so the verify harness can assert on `docker logs`.
NRC refuses to register an abonnement whose callback is unauthenticated
(`no-auth-on-callback`): when validating it sends a probe and expects the callback
to reject a request without the configured `Authorization` value. So this sink
enforces that header (EXPECTED_AUTH env) — 401 without it, 204 with it.
Stdlib only. Listens on :9000. See infra/verify-notifications.sh / S-01-c (#56).
"""
import http.server
import os
import sys
EXPECTED_AUTH = os.environ.get("EXPECTED_AUTH", "Bearer notification-sink-token")
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length).decode("utf-8", "replace")
if self.headers.get("Authorization") != EXPECTED_AUTH:
self.send_response(401)
self.end_headers()
return
print("NOTIFICATION " + body, flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, *args): # silence default request logging
pass
if __name__ == "__main__":
http.server.HTTPServer(("0.0.0.0", 9000), Handler).serve_forever()
sys.exit(0)

View File

@@ -52,11 +52,18 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
# Delivery cadence: nrc-beat fires `execute_notifications` this often to drain
# scheduled notifications to subscribers. Upstream default is 20s; 5s keeps the
# walking-skeleton + the verify smoke responsive.
NOTIFICATION_SEC_INTERVAL: "5"
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish
# notifications. data.yaml is streamed into this external volume by
# infra/seed-config.sh (same pattern as oz-init). See data.yaml + ADR-0006.
command: /setup_configuration.sh
volumes:
- nrc-config:/app/setup_configuration:ro
depends_on:
nrc-db:
condition: service_healthy
@@ -89,8 +96,25 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat: periodically fires `execute_notifications`, which drains the
# ScheduledNotification rows the API creates on publish and hands them to the
# worker for delivery. Without beat, notifications are accepted but never
# delivered to subscribers — so it is required, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
volumes:
nrc-db:
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
nrc-config:
external: true
name: rr-nrc-config
networks:
cg:

View File

@@ -1,5 +1,41 @@
# Open Notificaties setup_configuration.
# Stage 1 (this commit): intentionally minimal — the init container runs
# migrations; no steps enabled yet. The OpenZaak<->NRC notification wiring
# (Services, Authorization, JWT, Kanalen) is added next. See ADR-0002 / S-01-c.
{}
# Open Notificaties (NRC) setup_configuration (S-01-c, #56).
# Wires NRC so OpenZaak can publish notifications:
# - the JWT credential OpenZaak authenticates with,
# - delegation of authorization checks to OpenZaak's Autorisaties API (AC),
# - the `zaken` kanaal OpenZaak publishes zaak events on.
# Dev-only credentials — not for production. Steps from nrc.setup_configuration.
# 1. JWT credential NRC uses to verify the token OpenZaak presents.
vng_api_common_credentials_config_enable: true
vng_api_common_credentials:
items:
- identifier: big-reference-seed
secret: insecure-dev-secret-change-me
# 2. The Autorisaties API (OpenZaak's AC) NRC consults to authorize publishers.
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: openzaak-ac
label: OpenZaak Autorisaties API
api_type: ac
api_root: http://openzaak:8000/autorisaties/api/v1/
auth_type: zgw
client_id: big-reference-seed
secret: insecure-dev-secret-change-me
# 3. Delegate authorization to that AC.
autorisaties_api_config_enable: true
autorisaties_api:
authorizations_api_service_identifier: openzaak-ac
# 4. The kanaal OpenZaak publishes zaak events on.
notifications_kanalen_config_enable: true
notifications_kanalen_config:
items:
- naam: zaken
documentatie_link: https://github.com/VNG-Realisatie/gemma-zaken
filters:
- bronorganisatie
- zaaktype
- vertrouwelijkheidaanduiding

View File

@@ -47,9 +47,12 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
# Notifications go to Open Notificaties (NRC), which arrives in S-01-c.
# Until then, disable outbound notifications so writes don't 500.
NOTIFICATIONS_DISABLED: "true"
# Notifications are OFF by default so OpenZaak-only bring-ups (openzaak-up,
# the ACL integration test) don't 500 trying to reach an absent NRC. When
# OpenZaak runs together with the NRC stack, set OZ_NOTIFICATIONS_DISABLED=false
# (make stack-up does) to publish; the NRC service + notifications_config that
# name it are provisioned by setup_configuration (data.yaml, S-01-c).
NOTIFICATIONS_DISABLED: "${OZ_NOTIFICATIONS_DISABLED:-true}"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost

View File

@@ -18,6 +18,16 @@ SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
ZTC = f"{BASE}/catalogi/api/v1"
RSIN = "517439943" # elfproef-valid test RSIN
# Opt-in: also publish the zaaktype so OpenZaak's Zaken API accepts a zaak against
# it (a concept zaaktype is rejected with `not-published`). Off by default — the
# S-01 compose seed keeps it a concept (ADR-0002). The ACL integration test
# (S-04a, #46) sets OZ_PUBLISH=1. Publishing requires ≥2 statustypen, ≥1 roltype
# and ≥1 resultaattype; the resultaattype is validated against the external
# Selectielijst reference API, so this path needs outbound access to it. See ADR-0006.
PUBLISH = os.environ.get("OZ_PUBLISH", "").lower() in ("1", "true", "yes")
SELECTIELIJST = os.environ.get(
"OZ_SELECTIELIJST", "https://selectielijst.openzaak.nl/api/v1").rstrip("/")
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
@@ -52,6 +62,68 @@ def find(path):
return body.get("results", [])
def selectielijst(path):
"""GET the external Selectielijst reference API (no auth). Used only when publishing."""
req = urllib.request.Request(f"{SELECTIELIJST}{path}", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def publish_zaaktype(zt):
"""Add the relations OpenZaak requires to publish, then publish (idempotent).
Publish validation (verified against OpenZaak 1.28.2) demands: ≥2 statustypen
(begin + eind), ≥1 roltype, ≥1 resultaattype. A resultaattype needs a
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
"""
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
if volgnummer not in have_st:
st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
if st != 201:
sys.exit(f"create statustype {volgnummer} -> {st}: {json.dumps(body, indent=2)}")
print(f"create statustype {volgnummer} ({omschrijving})")
if find(f"/roltypen?zaaktype={zt['url']}&status=alles"):
print("skip roltype Aanvrager")
else:
st, body = api("POST", "/roltypen", {
"zaaktype": zt["url"], "omschrijving": "Aanvrager", "omschrijvingGeneriek": "initiator"})
if st != 201:
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager")
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
print("skip resultaattype Geregistreerd")
else:
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
# The selectielijstklasse and the zaaktype must share a procestype.
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
st, body = api("POST", "/resultaattypen", {
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
"archiefnominatie": "blijvend_bewaren",
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
print("create resultaattype Geregistreerd")
if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish")
if st != 200:
sys.exit(f"publish zaaktype -> {st}: {json.dumps(body, indent=2)}")
print(f"publish zaaktype BIG-REGISTRATIE ({zt['url']})")
else:
print("skip publish (already published)")
def main():
# 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
@@ -121,16 +193,28 @@ def main():
else:
print("warn zaaktype already published; cannot add bsn eigenschap")
# Intentionally NOT published. Publishing requires roltypen, resultaattypen
# and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this
# slice asks for; they arrive with the workflow/zaak slices. See ADR-0002.
# 4. Optionally publish. By default the zaaktype stays a concept: publishing
# requires roltypen, resultaattypen and statustypen, beyond the "lean /
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
# those relations and publish — needed so a real zaak POST is accepted, which
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
if PUBLISH:
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE")
publish_zaaktype(zt)
# 4. Verify the JWT client can list the zaaktype (concepts included).
# 5. Verify the JWT client can list the zaaktype (concepts included).
zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
names = [z.get("identificatie") for z in zaaktypen]
print(f"zaaktypen in BIG: {names}")
assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed"
print("OK — BIG catalogus seeded (BIG-REGISTRATIE concept + bsn eigenschap)")
state = "published" if PUBLISH else "concept"
# Machine-readable line so callers (e.g. infra/run-domain-check.sh) can capture the
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
print(f"ZAAKTYPE_URL {zt_url}")
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
if __name__ == "__main__":

View File

@@ -20,3 +20,22 @@ vng_api_common_applicaties:
- big-reference-seed
label: BIG reference seed client
heeft_alle_autorisaties: true
# ── OpenZaak → Open Notificaties (NRC) publishing (S-01-c, #56) ─────────────
# The NRC service OpenZaak posts notifications to, authenticating with the same
# big-reference-seed client (NRC verifies the JWT and authorizes it via the AC).
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: nrc
label: Open Notificaties
api_type: nrc
api_root: http://nrc-web:8000/api/v1/
auth_type: zgw
client_id: big-reference-seed
secret: insecure-dev-secret-change-me
# Point OpenZaak's notifications at that service. Requires NOTIFICATIONS_DISABLED=false.
notifications_config_enable: true
notifications_config:
notifications_api_service_identifier: nrc

37
infra/run-acl-integration.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Run the ACL integration tests (Category=Integration) against the OpenZaak that is
# ALREADY running — works for any stack: oz-only (`make integration`), the standalone
# oz+nrc stack, or the full compose stack (the CI `verify-stack` job). Seeds a
# published BIG zaaktype (idempotent), then builds + runs the test image on the stack
# network, reaching OpenZaak by container IP (a single-label host isn't URL-valid;
# the runner can't reach published ports — see gitea-actions-gotchas.md §5/§6).
#
# Does NOT manage the stack lifecycle: the caller owns bring-up + teardown. Plain
# docker primitives only (docker/podman-portable). See ADR-0006.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
# The OpenZaak API container, matched across compose projects + docker/podman naming
# (`<project>[-_]openzaak[-_]<n>`); the delimiters exclude oz-db / oz-redis / oz-init.
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container found — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$oz")"
oz_base="http://$oz_ip:8000"
echo ">> OpenZaak at $oz_base on network $net"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> building the integration test image"
docker build -f "$root/services/acl/Dockerfile.integration" -t rr-acl-integration "$root/services/acl"
echo ">> running the ACL integration tests (inside the network)"
docker run --rm --network "$net" -e "OZ_BASE=$oz_base" rr-acl-integration

58
infra/run-bff-check.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# Verify the BFF end-to-end (S-07) against an ALREADY-RUNNING full stack. The BFF is the portals'
# only backend (§8.3): it validates Keycloak digid tokens on the self-service submit and serves the
# openbaar register anonymously with only public-safe fields (ADR-0010).
#
# Checks, in-network (services reached by container IP; Keycloak by its service name so the token's
# host-derived issuer matches the BFF's authority — see the compose bff env and ADR-0010):
# 1. POST /self-service/registrations without a token -> 401
# 2. mint a real digid access token (direct grant) and POST it -> 202 (forwarded to the domain)
# 3. GET /openbaar/register (anonymous) -> 200 JSON array, never a bsn
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown). Plain docker primitives.
set -euo pipefail
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
kc="$(docker ps -q --filter 'name=keycloak' | head -1)"
[ -n "$bff" ] || { echo "ERROR: no running bff container — bring the stack up first" >&2; exit 1; }
[ -n "$kc" ] || { echo "ERROR: no running keycloak container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"
echo ">> bff=$bff_ip network=$net"
# Helper: run curl inside a throwaway container on the stack network (reaches services by name/IP).
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
echo ">> 1. self-service submit without a token must be 401"
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
-H 'Content-Type: application/json' -d '{}')"
echo " -> $code"; [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code" >&2; exit 1; }
echo ">> 2. minting a digid token (direct grant) via keycloak:8080 (host-consistent issuer)"
token=""
for _ in $(seq 1 20); do
token="$(net_curl -s -X POST "http://keycloak:8080/realms/digid/protocol/openid-connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' --data-urlencode 'client_id=big-portal' \
--data-urlencode 'username=jan-burger' --data-urlencode 'password=test123' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "$token" ] && break
sleep 3
done
[ -n "$token" ] || { echo "FAIL: could not obtain a digid access token" >&2; exit 1; }
echo " -> got a token"
echo ">> 2b. self-service submit with the token must be 202"
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}')"
echo " -> $code"; [ "$code" = "202" ] || { echo "FAIL: expected 202, got $code" >&2; docker logs "$bff" 2>&1 | tail -15 >&2; exit 1; }
echo ">> 3. openbaar register (anonymous) must be 200 JSON, never a bsn"
body="$(net_curl -s "http://$bff_ip:8080/openbaar/register")"
echo "$body" | grep -q '^\[' || { echo "FAIL: openbaar did not return a JSON array: $body" >&2; exit 1; }
if echo "$body" | grep -q '"bsn"'; then echo "FAIL: openbaar leaked a bsn field" >&2; exit 1; fi
echo "OK — BFF: 401 without token, 202 with a digid token, anonymous public-safe openbaar register"

68
infra/run-domain-check.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Verify the BIG Domain Service end-to-end (S-05) against an ALREADY-RUNNING full stack:
# domain → Flowable (start the registratie process + external-task worker) → ACL → OpenZaak.
# Submits a registration to the domain and asserts the worker opens a zaak in OpenZaak and
# records its URL on the aggregate (ADR-0009).
#
# The seeded zaaktype URL is server-assigned, so it isn't knowable at initial bring-up. This
# script therefore seeds a published BIG zaaktype and recreates the `acl` service configured to
# default-fill it — pointing the ACL at the SAME OpenZaak host that owns the URL, so zaak creation
# is host-consistent (exactly the configuration the ACL integration test proves, ADR-0006). That
# one recreate aside, the caller owns stack bring-up + teardown.
#
# All in-network, reaching services by container IP (a single-label host isn't URL-valid; the
# runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Plain docker primitives.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
compose="$root/infra/docker-compose.yml"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
dom="$(docker ps -q --filter 'name=domain' | head -1)"
[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container — bring the stack up first" >&2; exit 1; }
[ -n "$dom" ] || { echo "ERROR: no running domain container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
oz_base="http://$oz_ip:8000"
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
docker rm -f "$sid" >/dev/null
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
echo ">> zaaktype: $zt_url"
echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain"
loc="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc" ] || { echo "ERROR: POST /registrations returned no Location" >&2; exit 1; }
echo ">> registration accepted at $loc"
echo ">> polling the domain until the worker records the opened zaak"
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
echo "OK — the domain opened a zaak and recorded it on the registration:"
echo "$body" | cut -c1-300
exit 0
fi
sleep 2
done
echo "FAIL — the registration never received a zaak URL" >&2
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
exit 1

29
infra/run-e2e-check.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/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 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.
#
# Uses the official Playwright image with browsers pre-baked, instead of downloading ~150 MB of
# Chromium on every run (issue #73). The image tag MUST match tests/e2e/package.json's
# @playwright/test version — bump both together.
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 \
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && 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"

34
infra/run-integration.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# Local convenience: run the ACL integration test against a throwaway OpenZaak-only
# stack (fast iteration on the ACL gateway). Brings OpenZaak up, runs the shared
# stack-agnostic check (infra/run-acl-integration.sh), then always tears down.
#
# CI does not use this — there the full stack is brought up once and the same runner
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0006.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
cleanup() {
docker compose -f "$OZ_COMPOSE" down --volumes >/dev/null 2>&1 || true
docker volume rm -f rr-oz-config >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo ">> bringing OpenZaak up"
bash "$here/seed-config.sh" oz
docker compose -f "$OZ_COMPOSE" up -d
echo ">> waiting for the OpenZaak API container to be healthy"
for _ in $(seq 1 140); do
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
if [ -n "$oz" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$oz" 2>/dev/null || true)" = healthy ]; then
break
fi
sleep 3
done
[ -n "${oz:-}" ] || { echo "ERROR: OpenZaak never came up" >&2; exit 1; }
bash "$here/run-acl-integration.sh"

72
infra/run-notification-check.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Verify the OpenZaak → NRC notification path against an ALREADY-RUNNING oz+nrc stack
# (the standalone stack via `make verify-notifications`, or the full compose stack in
# the CI `verify-stack` job). Seeds a published BIG zaaktype (idempotent), registers
# an abonnement to a webhook sink, creates a zaak, and asserts the sink receives the
# `zaken`/`create` notification. All in-network, reaching services by container IP
# (single-label hosts aren't URL-valid; the runner can't reach published ports).
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown), but it
# cleans up the throwaway sink/driver it creates. Plain docker primitives only.
# See ADR-0007.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SINK_AUTH="Bearer notification-sink-token"
cleanup() { docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true; }
trap cleanup EXIT
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> starting the webhook sink"
docker rm -f rr-nsink >/dev/null 2>&1 || true
sink="$(docker create --network "$net" --name rr-nsink -e "EXPECTED_AUTH=$SINK_AUTH" \
python:3-slim python /sink.py)"
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
docker start "$sink" >/dev/null
sleep 1
sink_ip="$(ip rr-nsink)"
echo ">> sink at $sink_ip:9000"
echo ">> registering abonnement + creating a zaak"
docker rm -f rr-nverify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-nverify \
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
docker start -a "$drv"
zaak_url="$(docker logs rr-nverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
docker rm -f rr-nverify >/dev/null
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
zaak_uuid="${zaak_url##*/}"
echo ">> zaak created: $zaak_url"
echo ">> waiting for the notification to reach the sink"
for _ in $(seq 1 30); do
if docker logs rr-nsink 2>&1 | grep -q "$zaak_uuid"; then
echo "OK — NRC delivered the zaken notification for zaak $zaak_uuid to the sink"
docker logs rr-nsink 2>&1 | grep "$zaak_uuid" | tail -1 | cut -c1-300
exit 0
fi
sleep 2
done
echo "FAIL — the sink never received a notification for zaak $zaak_uuid" >&2
echo "--- sink log ---" >&2; docker logs rr-nsink 2>&1 | tail -8 >&2
exit 1

68
infra/run-projection-check.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack:
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak,
# and asserts projection-api serves a row for that zaak with status INGEDIEND.
#
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the
# notification driver to register the abonnement + create the zaak. Does NOT manage the stack
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
trap cleanup EXIT
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
proj="$(docker ps -q --filter 'name=projection-api' | head -1)"
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> registering abonnement at the Event Subscriber + creating a zaak"
docker rm -f rr-pverify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-pverify \
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
docker start -a "$drv"
zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
docker rm -f rr-pverify >/dev/null
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
zaak_uuid="${zaak_url##*/}"
echo ">> zaak created: $zaak_url"
echo ">> polling projection-api for the projected row (status INGEDIEND)"
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$proj_ip:8080/register/$zaak_uuid" 2>/dev/null || true)"
if echo "$body" | grep -q '"INGEDIEND"'; then
echo "OK — projection-api serves zaak $zaak_uuid with status INGEDIEND"
echo "$body" | cut -c1-300
exit 0
fi
sleep 2
done
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2
echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2
echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2
exit 1

View File

@@ -33,11 +33,12 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|kc|fl> ..." >&2; exit 2; }
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
for key in "$@"; do
case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Drive the OpenZaak → NRC notification check from *inside* the compose network.
Registers an abonnement on the `zaken` kanaal pointing at a webhook sink, then
creates a zaak against the published BIG zaaktype. OpenZaak publishes a
`zaken`/`create` notification; NRC delivers it to the sink. The host harness
(infra/verify-notifications.sh) then asserts the sink received it.
Reached by container IP, not service name: OpenZaak/NRC validate URLs with Django's
URLValidator, which rejects a single-label host like `openzaak`. Stdlib only.
Env: OZ_BASE, NRC_BASE, SINK_CALLBACK, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request
OZ = os.environ["OZ_BASE"].rstrip("/")
NRC = os.environ["NRC_BASE"].rstrip("/")
SINK_CALLBACK = os.environ["SINK_CALLBACK"]
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer notification-sink-token")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
RSIN = "517439943"
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
seg = (
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
+ b"."
+ b64(json.dumps(
{"iss": CID, "iat": int(time.time()), "client_id": CID,
"user_id": "verify", "user_representation": "verify"},
separators=(",", ":")).encode())
)
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
def call(method, url, body=None, crs=False):
headers = {"Authorization": "Bearer " + token(),
"Content-Type": "application/json", "Accept": "application/json"}
if crs:
headers["Accept-Crs"] = "EPSG:4326"
headers["Content-Crs"] = "EPSG:4326"
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(
urllib.request.Request(url, data=data, method=method, headers=headers), timeout=30
) as r:
return r.status, json.loads(r.read() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "null")
def main():
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": SINK_CALLBACK,
"auth": SINK_AUTH,
"kanalen": [{"naam": "zaken", "filters": {}}],
})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement: {ab['url']}")
status, body = call(
"GET", f"{OZ}/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief")
results = body.get("results", []) if status == 200 else []
if not results:
sys.exit("no published BIG-REGISTRATIE zaaktype — seed with OZ_PUBLISH=1 first")
zaaktype = results[0]["url"]
status, zaak = call("POST", f"{OZ}/zaken/api/v1/zaken", {
"bronorganisatie": RSIN, "verantwoordelijkeOrganisatie": RSIN,
"zaaktype": zaaktype, "startdatum": time.strftime("%Y-%m-%d"),
"vertrouwelijkheidaanduiding": "openbaar",
}, crs=True)
if status != 201:
sys.exit(f"create zaak -> {status}: {json.dumps(zaak)}")
# The harness greps the sink for this exact URL.
print(f"ZAAK_CREATED {zaak['url']}")
if __name__ == "__main__":
main()

41
infra/verify-notifications.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# Local convenience: verify the OpenZaak → NRC notification path against a throwaway
# oz+nrc stack. Brings both up (notifications enabled), runs the shared stack-agnostic
# check (infra/run-notification-check.sh), then always tears down.
#
# CI does not use this — there the full stack is brought up once and the same runner
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0007.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
NRC_COMPOSE="$here/opennotificaties/docker-compose.yml"
cleanup() {
docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" down --volumes >/dev/null 2>&1 || true
docker volume rm -f rr-oz-config rr-nrc-config >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_healthy() { # name-regex
local re="$1" cid
for _ in $(seq 1 140); do
cid="$(docker ps -q --filter "name=$re" | head -1)"
if [ -n "$cid" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)" = healthy ]; then
return 0
fi
sleep 3
done
return 1
}
echo ">> bringing up OpenZaak + Open Notificaties (notifications enabled)"
bash "$here/seed-config.sh" oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" up -d
echo ">> waiting for OpenZaak + NRC to be healthy"
wait_healthy '[-_]openzaak[-_]' || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; }
wait_healthy 'nrc-web' || { echo "ERROR: NRC not healthy" >&2; exit 1; }
bash "$here/run-notification-check.sh"

View File

@@ -0,0 +1,7 @@
# api-client
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test api-client` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'orval';
// Generates the BFF client (Angular HttpClient service + models) from the committed
// OpenAPI contract. Never hand-edit the generated output — re-run `nx run api-client:generate`
// after the BFF spec changes (CLAUDE.md §10; docs/frontend-decisions.md).
export default defineConfig({
bff: {
input: '../../services/bff/openapi.json',
output: {
target: './src/lib/generated/bff-api.ts',
client: 'angular',
mode: 'single',
clean: true,
prettier: true,
},
},
});

View File

@@ -0,0 +1,20 @@
{
"name": "api-client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api-client/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
},
"generate": {
"executor": "nx:run-commands",
"options": {
"command": "orval --config orval.config.ts",
"cwd": "libs/api-client"
}
}
}
}

View File

@@ -0,0 +1,3 @@
// The BFF API client is generated from services/bff/openapi.json (orval); never hand-edit
// src/lib/generated. Re-run `nx run api-client:generate` after the BFF spec changes.
export * from './lib/generated/bff-api';

View File

@@ -0,0 +1,50 @@
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import {
BffApiV1Service,
type OpenbaarEntry,
type SubmitAccepted,
} from '../index';
describe('BffApiV1Service (generated from services/bff/openapi.json)', () => {
let service: BffApiV1Service;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(BffApiV1Service);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('submits a registration via POST /self-service/registrations', () => {
let result: SubmitAccepted | undefined;
service.postSelfServiceRegistrations().subscribe((r) => (result = r));
const req = http.expectOne('/self-service/registrations');
expect(req.request.method).toBe('POST');
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
expect(result?.registrationId).toBe('reg-1');
expect(result?.status).toBe('Ingediend');
});
it('reads the openbaar register via GET /openbaar/register with the query', () => {
let rows: OpenbaarEntry[] | undefined;
service.getOpenbaarRegister({ q: 'abc' }).subscribe((r) => (rows = r));
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('q')).toBe('abc');
req.flush([{ id: 'abc-111', status: 'INGEDIEND' }]);
expect(rows?.[0].id).toBe('abc-111');
});
});

View File

@@ -0,0 +1,216 @@
/**
* Generated by orval v8.19.0 🍺
* Do not edit manually.
* Bff.Api | v1
* OpenAPI spec version: 1.0.0
*/
import {
HttpClient,
HttpHeaders,
HttpResponse as AngularHttpResponse
} from '@angular/common/http';
import type {
HttpContext,
HttpEvent,
HttpParams
} from '@angular/common/http';
import {
Injectable,
inject
} from '@angular/core';
import {
Observable
} from 'rxjs';
export interface OpenbaarEntry {
id: string;
status: string;
}
export interface SubmitAccepted {
registrationId: string;
status: string;
}
export type GetOpenbaarRegisterParams = {
q?: string;
};
interface HttpClientOptions {
readonly headers?: HttpHeaders | Record<string, string | string[]>;
readonly context?: HttpContext;
readonly params?:
| HttpParams
| Record<string, string | number | boolean | Array<string | number | boolean>>;
readonly reportProgress?: boolean;
readonly withCredentials?: boolean;
readonly credentials?: RequestCredentials;
readonly keepalive?: boolean;
readonly priority?: RequestPriority;
readonly cache?: RequestCache;
readonly mode?: RequestMode;
readonly redirect?: RequestRedirect;
readonly referrer?: string;
readonly integrity?: string;
readonly referrerPolicy?: ReferrerPolicy;
readonly transferCache?: {includeHeaders?: string[]} | boolean;
readonly timeout?: number;
}
type HttpClientBodyOptions = HttpClientOptions & {
readonly observe?: 'body';
};
type HttpClientEventOptions = HttpClientOptions & {
readonly observe: 'events';
};
type HttpClientResponseOptions = HttpClientOptions & {
readonly observe: 'response';
};
type HttpClientObserveOptions = HttpClientOptions & {
readonly observe?: 'body' | 'events' | 'response';
};
type AngularHttpParamValue = string | number | boolean | Array<string | number | boolean>;
type AngularHttpParamValueWithNullable = AngularHttpParamValue | null;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys?: ReadonlySet<string>,
preserveRequiredNullables?: false,
passthroughKeys?: undefined,
): Record<string, AngularHttpParamValue>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> | undefined,
preserveRequiredNullables: true,
passthroughKeys?: undefined,
): Record<string, AngularHttpParamValueWithNullable>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> | undefined,
preserveRequiredNullables: boolean | undefined,
passthroughKeys: ReadonlySet<string>,
): Record<string, unknown>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> = new Set(),
preserveRequiredNullables = false,
passthroughKeys: ReadonlySet<string> = new Set(),
): Record<string, unknown> {
const filteredParams: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (passthroughKeys.has(key)) {
if (value !== undefined) {
filteredParams[key] = value;
}
continue;
}
if (Array.isArray(value)) {
const filtered = value.filter(
(item) =>
item != null &&
(typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'),
) as Array<string | number | boolean>;
if (filtered.length) {
filteredParams[key] = filtered;
}
} else if (
preserveRequiredNullables &&
value === null &&
requiredNullableKeys.has(key)
) {
filteredParams[key] = null;
} else if (
value != null &&
(typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean')
) {
filteredParams[key] = value;
}
}
return filteredParams;
}
@Injectable({ providedIn: 'root' })
export class BffApiV1Service {
private readonly http = inject(HttpClient);
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrations<TData = SubmitAccepted>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(
params?: GetOpenbaarRegisterParams, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
const filteredParams = filterParams({...params, ...options?.params}, new Set<string>([]));
if (options?.observe === 'events') {
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
params: filteredParams,}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
params: filteredParams,}
);
}
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
params: filteredParams,}
);
}
};

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed({ zoneless: false });

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/api-client',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'api-client',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/api-client',
provider: 'v8' as const,
},
},
}));

7
libs/auth/README.md Normal file
View File

@@ -0,0 +1,7 @@
# auth
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test auth` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

13
libs/auth/project.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "auth",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/auth/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

4
libs/auth/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './lib/auth.service';
export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers';
export * from './lib/authenticated.guard';

View File

@@ -0,0 +1,16 @@
import { Signal } from '@angular/core';
/**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
*/
export abstract class AuthService {
/** Whether a DigiD session is active. */
abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated. */
abstract readonly bsn: Signal<string | undefined>;
/** Start the DigiD login (redirects to Keycloak). */
abstract login(): void;
/** End the session. */
abstract logout(): void;
}

View File

@@ -0,0 +1,42 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { authenticatedGuard } from './authenticated.guard';
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(false);
readonly bsn = signal<string | undefined>(undefined);
login(): void {
/* spied in tests */
}
logout(): void {
/* noop */
}
}
function runGuard(authenticated: boolean) {
const auth = new FakeAuth();
auth.isAuthenticated.set(authenticated);
const loginSpy = vi.spyOn(auth, 'login');
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: auth }],
});
const result = TestBed.runInInjectionContext(() =>
authenticatedGuard(null as never, null as never),
);
return { result, loginSpy };
}
describe('authenticatedGuard', () => {
it('allows the route for an authenticated user', () => {
const { result, loginSpy } = runGuard(true);
expect(result).toBe(true);
expect(loginSpy).not.toHaveBeenCalled();
});
it('blocks and starts DigiD login when not authenticated', () => {
const { result, loginSpy } = runGuard(false);
expect(result).toBe(false);
expect(loginSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,13 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
/** Allow the route only when a DigiD session is active; otherwise start the login. */
export const authenticatedGuard: CanActivateFn = () => {
const auth = inject(AuthService);
if (auth.isAuthenticated()) {
return true;
}
auth.login();
return false;
};

View File

@@ -0,0 +1,55 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import {
authInterceptor,
LogLevel,
provideAuth,
withAppInitializerAuthCheck,
} from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { DigiadAuthService } from './digid-auth.service';
export interface DigiadAuthOptions {
/** The Keycloak `digid` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: 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[];
}
/**
* Configure DigiD login (Keycloak `digid` realm, public client `big-portal`, auth-code + PKCE) and
* bind {@link AuthService} to the OIDC-backed implementation. Register {@link authInterceptor} in the
* app's HttpClient so BFF calls carry the token.
*/
export function provideDigiadAuth(options: DigiadAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the DigiD callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: DigiadAuthService },
]);
}
export { authInterceptor };

View File

@@ -0,0 +1,29 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** DigiD-backed AuthService over angular-auth-oidc-client (Keycloak `digid` realm). */
@Injectable()
export class DigiadAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
readonly bsn: Signal<string | undefined> = toSignal(
this.oidc.userData$.pipe(map((data) => (data.userData as { bsn?: string } | null)?.bsn)),
{ initialValue: undefined },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed({ zoneless: false });

31
libs/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

28
libs/auth/vite.config.mts Normal file
View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/auth',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'auth',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/auth',
provider: 'v8' as const,
},
},
}));

7
libs/ui/README.md Normal file
View File

@@ -0,0 +1,7 @@
# ui
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test ui` to execute the unit tests.

34
libs/ui/eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

13
libs/ui/project.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "ui",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

11
libs/ui/src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// NL Design System building blocks — Utrecht is NL DS's reference Angular implementation
// (docs/frontend-decisions.md). The portals depend on the design system through this one lib.
// The design tokens/theme CSS is imported once, at the app level (apps/*/src/styles.css).
//
// The Utrecht v3 components are NgModule-based (not standalone), so we re-export the module.
// A standalone component consumes them by importing UtrechtComponentsModule in its `imports`
// (CLAUDE.md §10 forbids *declaring* NgModules in our code, not consuming a third-party one).
// Re-export the whole Utrecht package: importing UtrechtComponentsModule pulls every component it
// exports into the compiler's scope, so the AOT build needs all of them resolvable through this
// barrel (a partial re-export fails with NG3004 for the first unused component).
export * from '@utrecht/component-library-angular';

View File

@@ -0,0 +1,7 @@
import { UtrechtComponentsModule } from '../index';
describe('ui barrel', () => {
it('re-exports the NL Design System (Utrecht) module', () => {
expect(UtrechtComponentsModule).toBeTruthy();
});
});

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed({ zoneless: false });

31
libs/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

26
libs/ui/tsconfig.lib.json Normal file
View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

28
libs/ui/vite.config.mts Normal file
View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/ui',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'ui',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/ui',
provider: 'v8' as const,
},
},
}));

View File

@@ -26,7 +26,15 @@ nav:
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
- "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
- "ADR-0010: BFF OIDC validation": architecture/adr-0010-bff-oidc.md
- Working in Gitea: gitea-workflow.md
- Frontend decisions: frontend-decisions.md
- Demo script: demo-script.md
- Runbooks:
- CI: runbooks/ci.md

120
nx.json Normal file
View File

@@ -0,0 +1,120 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": [
"{projectRoot}/**/*",
"sharedGlobals"
],
"production": [
"default",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.mjs",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/test-setup.[jt]s"
],
"sharedGlobals": []
},
"targetDefaults": {
"@angular/build:application": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@nx/esbuild:esbuild": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/js:tsc": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/vitest:test": {
"cache": true,
"inputs": [
"default",
"^production"
]
},
"@angular/build:unit-test": {
"cache": true,
"inputs": [
"default",
"^production"
]
}
},
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"serveTargetName": "serve",
"devTargetName": "dev",
"previewTargetName": "preview",
"serveStaticTargetName": "serve-static",
"typecheckTargetName": "typecheck",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps"
}
},
{
"plugin": "@nx/vitest",
"options": {
"testTargetName": "test"
}
}
],
"generators": {
"@nx/angular:application": {
"e2eTestRunner": "playwright",
"linter": "eslint",
"style": "css",
"unitTestRunner": "vitest-analog"
},
"@nx/angular:library": {
"linter": "eslint",
"unitTestRunner": "vitest-analog"
},
"@nx/angular:component": {
"style": "css"
}
},
"analytics": false
}

71
package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "register-referentie",
"version": "0.0.0",
"license": "MIT",
"scripts": {},
"private": true,
"dependencies": {
"@angular-devkit/build-angular": "21.2.7",
"@angular/common": "21.2.9",
"@angular/compiler": "21.2.9",
"@angular/core": "21.2.9",
"@angular/forms": "21.2.9",
"@angular/platform-browser": "21.2.9",
"@angular/router": "21.2.9",
"@utrecht/component-library-angular": "^3.0.2",
"@utrecht/design-tokens": "^6.2.1",
"angular-auth-oidc-client": "^21.0.2",
"rxjs": "~7.8.0",
"zone.js": "0.16.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "2.2.0",
"@analogjs/vitest-angular": "2.2.0",
"@angular-devkit/core": "21.2.7",
"@angular-devkit/schematics": "21.2.7",
"@angular/build": "21.2.7",
"@angular/cli": "21.2.7",
"@angular/compiler-cli": "21.2.9",
"@angular/language-service": "21.2.9",
"@eslint/js": "^9.8.0",
"@nx/angular": "23.0.1",
"@nx/devkit": "23.0.1",
"@nx/esbuild": "23.0.1",
"@nx/eslint": "23.0.1",
"@nx/eslint-plugin": "23.0.1",
"@nx/js": "23.0.1",
"@nx/vite": "23.0.1",
"@nx/vitest": "23.0.1",
"@nx/web": "23.0.1",
"@nx/workspace": "23.0.1",
"@oxc-project/runtime": "^0.115.0",
"@schematics/angular": "21.2.7",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.8",
"@swc/helpers": "0.5.18",
"@testing-library/angular": "^19.4.1",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "^8.40.0",
"@vitest/coverage-v8": "~4.1.0",
"@vitest/ui": "~4.1.0",
"angular-eslint": "21.3.1",
"axe-core": "^4.12.1",
"esbuild": "^0.27.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0",
"nx": "23.0.1",
"orval": "^8.19.0",
"prettier": "^3.8.1",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "8.0.9",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "~4.1.0",
"vitest-axe": "^0.1.0"
}
}

18863
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
packages: []
allowBuilds:
'@parcel/watcher': true
less: true
esbuild: true
'@swc/core': true
nx: true
unrs-resolver: true
lmdb: true
msgpackr-extract: true

Some files were not shown because too many files have changed in this diff Show More