Compare commits

..

43 Commits

Author SHA1 Message Date
1c185e6686 S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m13s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m35s
CI / mutation (push) Successful in 6m6s
CI / verify-stack (push) Successful in 7m34s
## What & why

S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar.

## Changes (bottom-up, each red→green TDD)

- **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`.
- **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test.
- **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status.
- **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN.
- **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note.

## Key decisions (see ADR-0011)

- **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant.
- **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+).

## Verification

- All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean.
- No new compose config (the eindstatus-discovery approach avoided it).
- The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally).

closes #75

Reviewed-on: #77
2026-07-14 09:04:57 +00:00
bc9831c113 S-09: Openbaar Register portal — public lookup (#76)
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m57s
CI / mutation (push) Successful in 5m19s
CI / verify-stack (push) Successful in 6m31s
Anonymous openbaar portal completing the walking skeleton (submit → projection → public visibility).

closes #10
2026-07-13 14:35:34 +00:00
7e8c5d7b51 Merge pull request 'ci: speed up pipeline — NuGet cache + prebuilt Playwright image' (#74) from chore/73-ci-speedups into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build (push) Successful in 57s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m46s
CI / mutation (push) Successful in 4m5s
CI / verify-stack (push) Successful in 6m22s
Reviewed-on: #74
2026-07-13 13:59:15 +00:00
2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
All checks were successful
CI / lint (pull_request) Successful in 5m43s
CI / build (pull_request) Successful in 56s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
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 / 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
CI / verify-stack (push) Successful in 7m19s
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 / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
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 / frontend (pull_request) Successful in 1m48s
CI / mutation (pull_request) Successful in 3m57s
CI / verify-stack (pull_request) Successful in 7m59s
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 / frontend (pull_request) Successful in 1m30s
CI / mutation (pull_request) Successful in 3m54s
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 / frontend (pull_request) Successful in 1m27s
CI / mutation (pull_request) Successful in 3m56s
CI / verify-stack (pull_request) Successful in 5m46s
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
154 changed files with 23657 additions and 47 deletions

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 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' 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 - run: make lint
build: build:
@@ -32,6 +42,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' 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 - run: make build
unit: unit:
@@ -41,8 +57,28 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' 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 - run: make unit
# 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
- 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: mutation:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -50,6 +86,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' 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 - run: make mutation
# Publish the Stryker HTML reports. `if: always()` uploads them even when the # Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors. # ratchet fails — that is exactly when you want to inspect the survivors.
@@ -79,6 +121,13 @@ jobs:
name: domain-mutation-report name: domain-mutation-report
path: services/domain/StrykerOutput/**/reports/mutation-report.html path: services/domain/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn 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 # 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) # runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
@@ -101,10 +150,14 @@ jobs:
run: make verify-projection run: make verify-projection
- name: Domain → Flowable → ACL → OpenZaak - name: Domain → Flowable → ACL → OpenZaak
run: make verify-domain 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). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down

22
.gitignore vendored
View File

@@ -35,3 +35,25 @@ site/
# OS # OS
.DS_Store .DS_Store
Thumbs.db 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,32 +151,47 @@ 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 ### 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.
- **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.
**Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Acceptance:** **Acceptance:**
- E2E test (Playwright): full happy path, login → submit → success page. - E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Component tests (Testing Library) for the form. - Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
- 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. **Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Out of scope:** document upload, status tracking page. **Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
### S-09 · Openbaar Register portal — public lookup ### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end. **Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Acceptance:** **Acceptance:**
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry. - A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Public-safe field whitelist enforced and tested. - Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
**Touches:** `apps/openbaar/`, projection-api hardening, tests. **Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Out of scope:** advanced search filters, sorting. **Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases. **End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
--- ---

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # 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) # (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. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # 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 # bind-mounted, because bind mounts don't reach sibling containers on the
@@ -43,11 +43,23 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
endif endif
.PHONY: ci lint build unit mutation integration verify verify-up verify-acl verify-nrc verify-projection 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 .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, mutation, verify (mirrors Gitea Actions) ## 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). ## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation verify 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: verify formatting (no changes)
lint: lint:
@@ -64,14 +76,14 @@ unit:
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) ## 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` # 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 # makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json, # threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json.
# Scores never regress below baseline. # Scores never regress below baseline.
mutation: mutation:
dotnet tool restore dotnet tool restore
cd services/acl && dotnet stryker cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker cd services/event-subscriber && dotnet stryker
cd services/domain && 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 ## 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; # SEED populates the external config volumes first (upstream images used verbatim;
@@ -143,6 +155,16 @@ verify-projection:
verify-domain: verify-domain:
bash infra/run-domain-check.sh 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, ## 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` ## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead. ## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -154,7 +176,9 @@ verify:
&& bash infra/run-acl-integration.sh \ && bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \ && bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \ && bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh || rc=$$?; \ && 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 compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc' exit $$rc'

21
apps/openbaar/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Multi-stage build for the openbaar 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/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
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: {},
},
];

23
apps/openbaar/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
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 anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
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": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/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": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar: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": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};

View File

View File

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

View File

@@ -0,0 +1,4 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];

View File

@@ -0,0 +1,14 @@
import { provideRouter } from '@angular/router';
import { render } 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 RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).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 = 'openbaar';
}

View File

@@ -0,0 +1,54 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.id }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND' },
{ id: 'zaak-def', status: 'INGESCHREVEN' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/zaak-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { 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,45 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Openbaar BIG-register</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,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).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,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,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.

View File

@@ -0,0 +1,64 @@
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
- **Status:** Accepted
- **Date:** 2026-07-13
- **Deciders:** Respellion engineering
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
## Context
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
**temporary admin endpoint** on the Domain Service.
Two decisions are non-obvious (§14) and cross service boundaries:
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
not carry the statustype.
## Decision
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
notification's own fields on the read side.**
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
translation (§8.1, ADR-0003).
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
reproduces the right status.
## Consequences
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
statustype knowledge lives only in the ACL.
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
resource, which is all a rebuild needs to reproject the status.
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
here so future-me revisits this rather than assuming it generalises.
## Alternatives considered
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
should retain notification *facts*, not projection semantics; the mapping stays in the projector.

View File

@@ -5,6 +5,109 @@ 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 ## S-05 — BIG Domain Service: submit a registration
**Outcome:** submitting a registration starts a Flowable process; the external-task worker **Outcome:** submitting a registration starts a Flowable process; the external-task worker
@@ -65,3 +168,62 @@ curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and > `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. > the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
---
## S-09 — Openbaar Register portal (public visibility)
**Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the
anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection
→ public visibility).
**The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber →
projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`.
```bash
# 1. Bring the full stack up (self-service :8140, openbaar :8141).
make up
# 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123),
# or drive the whole happy path automatically (login → submit → public visibility):
make verify-e2e
# 3. Open the public register — no login. It lists the submitted entry (id + status only).
# Only public-safe fields cross the BFF: bsn / naam never appear.
open http://localhost:8141/ # search box; searches the BFF by referentie
curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
```
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below.
---
## S-09b — Approval flow (public visibility flips to INGESCHREVEN)
**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no
behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows
back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`.
**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW
`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register.
```bash
# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN:
make up
make verify-e2e
# 2. Or by hand: submit (as in S-09), note the reference, then approve it.
# The zaak is opened off the request path, so approve once GET shows a zaakUrl.
ref="<registration-reference-from-the-confirmation>"
curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl
curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content
# 3. The public register now shows the entry as approved.
curl -fsS http://localhost:8140/openbaar/register | jq
# → [ { "id": "<zaak-uuid>", "status": "INGESCHREVEN" } ]
```
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).

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

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

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

@@ -344,6 +344,13 @@ services:
context: ../services/bff context: ../services/bff
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev 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: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -352,6 +359,13 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
start_period: 10s start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg] networks: [cg]
# ── Read projection (S-06) ──────────────────────────────────────────────── # ── Read projection (S-06) ────────────────────────────────────────────────
@@ -419,6 +433,52 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] 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]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141: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
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

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"

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"

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

@@ -31,7 +31,9 @@ nav:
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.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-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-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 - Working in Gitea: gitea-workflow.md
- Frontend decisions: frontend-decisions.md
- Demo script: demo-script.md - Demo script: demo-script.md
- Runbooks: - Runbooks:
- CI: runbooks/ci.md - 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

View File

@@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT
return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
}); });
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
// URL; the ACL owns the ZGW statustype resolution (§8.1).
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
{
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
return Results.NoContent();
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn); public sealed record OpenZaakRequest(string Bsn);
public sealed record SetStatusRequest(string ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
/// <summary>
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary>
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
} }

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