Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af83194e79 | ||
|
|
756e718ee2 | ||
|
|
53e9564f94 | ||
|
|
5325a99755 | ||
|
|
036005e486 | ||
|
|
ae7fc1b8f0 | ||
|
|
1abd4b6472 | ||
|
|
d354fe507a | ||
|
|
4c516cdad3 | ||
|
|
9d327bbd81 | ||
|
|
3c344caa29 | ||
|
|
ecad42873c | ||
|
|
4b4b58b486 | ||
|
|
dca9455bb5 | ||
|
|
aaa7135fb1 | ||
|
|
4777ff2b1d | ||
|
|
ccae27b3da | ||
|
|
7bcbc726ce | ||
|
|
8a537edd6c | ||
|
|
e7bed37cda | ||
|
|
94699f3603 | ||
|
|
951bdd8364 | ||
|
|
2397d9196a | ||
|
|
a34caba9ea | ||
|
|
1f1c944a8b | ||
|
|
3abf8f7ccf | ||
|
|
d226b6402d | ||
|
|
9c3da48d8e | ||
|
|
4085bdead7 | ||
|
|
d4ed0ffc22 | ||
|
|
3023bb6fbe | ||
|
|
9997da8beb | ||
|
|
1c185e6686 | ||
|
|
bc9831c113 | ||
|
|
7e8c5d7b51 | ||
|
|
2b9eb5eb41 | ||
|
|
60df0845aa | ||
|
|
2a746736dc | ||
|
|
986e36bc7d | ||
|
|
7e152e4432 | ||
|
|
5bf25f094d | ||
|
|
0e6c7d2066 | ||
|
|
39923e0e68 | ||
|
|
2e00ad38ba | ||
|
|
be016f920c | ||
|
|
d3f23a4da3 | ||
|
|
490e7347b0 | ||
|
|
4f311c9b5a | ||
|
|
a55ba1160d | ||
|
|
4416d1f4ed | ||
|
|
074101e836 | ||
|
|
5089c2aea6 | ||
|
|
29f3dcc6cf | ||
|
|
2c196245c2 | ||
|
|
72c2bdfae7 | ||
|
|
311aab0aba | ||
|
|
fcdb117768 | ||
|
|
c3f0710a18 | ||
|
|
7c363099ff | ||
|
|
a069ab07a2 | ||
|
|
34969659f7 | ||
|
|
fd90c4abe2 | ||
|
|
3824f85af6 | ||
|
|
ef877ebc80 | ||
|
|
9c961f9a13 | ||
|
|
0b82841b14 | ||
|
|
5a4331a416 | ||
|
|
96d447832f | ||
|
|
a07d8277d6 | ||
|
|
69d6e80378 | ||
|
|
5d32d4f15e | ||
|
|
d767430ad7 | ||
|
|
751ca006a7 | ||
|
|
fea806848b | ||
|
|
2f5d656b54 | ||
|
|
72efab3ae0 | ||
|
|
1edd34e2db | ||
|
|
f885e0a3be | ||
|
|
ac874bf746 | ||
|
|
67f0ffb88d | ||
|
|
5a3f28ac6d | ||
|
|
e9a873c152 | ||
|
|
79dcd8f14b | ||
|
|
22ab38f328 | ||
|
|
0d34d60797 | ||
|
|
6d4adaf957 | ||
|
|
39b2388a9d | ||
|
|
8d176c2603 | ||
|
|
53751fd1bc | ||
|
|
cc9e7852e1 | ||
|
|
c9edf27a48 | ||
|
|
c3ccffe417 | ||
|
|
0d0778036e | ||
|
|
fa8382fc02 | ||
|
|
06d8d13e19 | ||
|
|
a111e5cc20 | ||
|
|
7ef63c7ae9 | ||
|
|
017cd5e66b | ||
|
|
c70840e5b7 | ||
|
|
32c98f00db | ||
|
|
d49443353e | ||
|
|
a256db1a23 | ||
|
|
4d07285dcd | ||
|
|
f3e9db7147 | ||
|
|
86cc65f4d9 | ||
|
|
4474585606 | ||
|
|
3829cb0b68 | ||
|
|
855a5565fe | ||
|
|
09de500fb8 | ||
|
|
4322c607cb | ||
|
|
d0582cef65 | ||
|
|
f2e575b427 | ||
|
|
fd5fa5ac3c | ||
|
|
5f3dd31925 | ||
|
|
347713766e | ||
|
|
7ecc184111 | ||
|
|
e8510bf9c3 | ||
|
|
6ac2fca384 | ||
|
|
10816f5303 | ||
|
|
89b097d015 | ||
|
|
5a83216395 | ||
|
|
f9e123dfcb | ||
|
|
e87113da24 | ||
|
|
dda4c58e1c | ||
|
|
b349dff496 | ||
|
|
6d8e1d0830 | ||
|
|
a0aa22c80b | ||
|
|
12049a0f35 | ||
|
|
9ff7937055 | ||
|
|
88de47d1bb | ||
|
|
8528664660 | ||
|
|
f32fc4e8c0 | ||
|
|
eaca611842 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-stryker": {
|
||||
"version": "4.15.0",
|
||||
"commands": [
|
||||
"dotnet-stryker"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.0",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+119
-6
@@ -17,34 +17,147 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
|
||||
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
|
||||
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
|
||||
# — a miss just restores from the network. See issue #73.
|
||||
- uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make lint
|
||||
|
||||
build:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make build
|
||||
|
||||
unit:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make unit
|
||||
|
||||
compose-smoke:
|
||||
runs-on: respellion-linux
|
||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- run: make smoke
|
||||
- uses: https://github.com/pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
- run: make frontend
|
||||
|
||||
mutation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make mutation
|
||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
|
||||
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
|
||||
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
|
||||
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acl-mutation-report
|
||||
path: services/acl/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: event-subscriber-mutation-report
|
||||
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: domain-mutation-report
|
||||
path: services/domain/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: bff-mutation-report
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (base images, nuget, selectielijst.openzaak.nl).
|
||||
verify-stack:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||
- name: Bring up the full stack & wait for health
|
||||
run: make verify-up
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
run: make verify-projection
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
run: make verify-bff
|
||||
- name: Self-service e2e (Playwright, login → submit → success)
|
||||
run: make verify-e2e
|
||||
# Log dump must precede teardown (which removes the containers).
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=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 openbaar behandel 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
+25
@@ -15,6 +15,9 @@ coverage*.json
|
||||
coverage*.xml
|
||||
*.coverage
|
||||
|
||||
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
|
||||
StrykerOutput/
|
||||
|
||||
# Rider / VS / VS Code
|
||||
.idea/
|
||||
.vs/
|
||||
@@ -32,3 +35,25 @@ site/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ── Frontend (Nx / Angular / pnpm) ──
|
||||
node_modules/
|
||||
dist/
|
||||
tmp/
|
||||
out-tsc/
|
||||
/coverage
|
||||
.angular/
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.nx/self-healing
|
||||
.nx/migrate-runs
|
||||
.nx/polygraph
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
.angular
|
||||
|
||||
# Playwright e2e (installed/generated in-container or on local runs)
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
/.nx/workspace-data
|
||||
.angular
|
||||
|
||||
.nx/self-healing
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
+44
-15
@@ -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
|
||||
|
||||
**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 1–2 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:**
|
||||
|
||||
- E2E test (Playwright): full happy path, login → submit → success page.
|
||||
- Component tests (Testing Library) for the form.
|
||||
- Accessibility audit (axe-core) passes WCAG 2.1 AA on the submit page.
|
||||
- E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
|
||||
- Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
|
||||
|
||||
**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:**
|
||||
|
||||
- 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.
|
||||
- Public-safe field whitelist enforced and tested.
|
||||
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,9 +199,23 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
||||
|
||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||
|
||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
||||
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||
|
||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
||||
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||
|
||||
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||
|
||||
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||
|
||||
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||
|
||||
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
|
||||
|
||||
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
|
||||
|
||||
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
||||
|
||||
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a cancellation status (not just the domain aggregate → `Verlopen`). Adds a cancellation statustype/resultaattype to the seed + an ACL method + expiry-worker wiring. Carved from S-10b (ADR-0017/0018). Depends on #103.
|
||||
|
||||
### S-11 · Withdrawal (Flow 3)
|
||||
|
||||
|
||||
+112
-1
@@ -2,19 +2,130 @@
|
||||
|
||||
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
||||
|
||||
## Unreleased
|
||||
## v2026.07.0 — 2026-07-14
|
||||
|
||||
### Architecture
|
||||
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
|
||||
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
|
||||
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
|
||||
- ADR-0009 external-task job-worker pattern (refs #6, #60)
|
||||
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
|
||||
|
||||
### Bug Fixes
|
||||
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
|
||||
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
|
||||
- Bake config into images so compose-smoke passes on CI (refs #30)
|
||||
- Nrc-init runs migrations only, not setup_configuration (refs #30)
|
||||
- Smoke waits on durable services, not the whole project (refs #30)
|
||||
- Portable health poll instead of compose --wait (refs #30)
|
||||
- Pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
|
||||
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
|
||||
- Keep dotnet format green under the shared .editorconfig (refs #65)
|
||||
- Re-export the full Utrecht package from libs/ui (refs #67)
|
||||
- Run checkAuth() at startup to end the login redirect loop (refs #67)
|
||||
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
|
||||
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
|
||||
- Attach the DigiD token to relative BFF calls (refs #68)
|
||||
|
||||
### Build
|
||||
- Pin Stryker.NET as a local dotnet tool (refs #47)
|
||||
|
||||
### CI
|
||||
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
||||
- ACL Dockerfile + full compose stack for smoke test (refs #30)
|
||||
- Switch runner label to ubuntu-latest (refs #30)
|
||||
- Run the mutation ratchet as a parallel CI job (refs #47)
|
||||
- Publish the Stryker HTML report as a CI artifact (refs #47)
|
||||
- Run the ACL integration test as a Gitea Actions job (refs #46)
|
||||
- Keep the integration lane local-only; document the runner gap (refs #46)
|
||||
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
|
||||
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
|
||||
- Containerize, wire into compose, and verify end-to-end (refs #6)
|
||||
- Make Stryker report upload best-effort (refs #62)
|
||||
- Retrigger after runner cleanup (refs #6)
|
||||
- Retrigger CI (refs #6)
|
||||
- Retrigger CI after gitea restart (refs #6)
|
||||
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
|
||||
- Nx frontend lane (lint/test/build) (refs #65)
|
||||
- Serve the self-service app in compose (refs #68)
|
||||
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
|
||||
- Cache the NuGet package store across the .NET jobs (refs #73)
|
||||
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
|
||||
|
||||
### Chores
|
||||
- Add idempotent Gitea backlog seeder
|
||||
- Remove bootstrap scripts from main (#35)
|
||||
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||
|
||||
### Documentation
|
||||
- Split S-00 into sub-slices (refs #1) (#33)
|
||||
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
|
||||
- Tighten gitea-actions-gotchas, add local compose (refs #30)
|
||||
- ADR-0008 read projection store + demo note for the event path (refs #7)
|
||||
- Demo note for submitting a registration (S-05) (refs #6)
|
||||
- Demo note for the BFF front door (S-07) (refs #8)
|
||||
- Split S-08 into S-08a-d (refs #65)
|
||||
- Frontend-decisions + demo note for S-08a (refs #65)
|
||||
- Record the orval generator choice (refs #66)
|
||||
- Record NL DS + DigiD decisions and demo note (refs #67)
|
||||
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
|
||||
|
||||
### Features
|
||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
||||
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
|
||||
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
|
||||
- Open Notificaties up + shared network (closes #2) (#42)
|
||||
- Keycloak with four mock realms (closes #3) (#43)
|
||||
- Flowable + registratie.bpmn external task (closes #4) (#44)
|
||||
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
|
||||
- Add bind-mount local compose for no-make/Windows dev (refs #30)
|
||||
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
|
||||
- Wire OpenZaak → Open Notificaties notifications (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Persist the read projection and expose webhook + read APIs (refs #7)
|
||||
- Enforce the callback bearer before reading the body (refs #7)
|
||||
- Implement the Registration aggregate invariants (refs #6)
|
||||
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
|
||||
- Implement the Flowable Workflow Client and ACL client (refs #6)
|
||||
- Expose POST /registrations and the read endpoint (refs #6)
|
||||
- Implement self-service submit and openbaar lookup (refs #8)
|
||||
- Committed OpenAPI contract + drift guard (refs #8)
|
||||
- Self-service portal placeholder page (refs #65)
|
||||
- Expose the generated BFF client + repeatable generate target (refs #66)
|
||||
- Implement the DigiD registration submit page (refs #67)
|
||||
- Runtime config + nginx serve/proxy image (refs #68)
|
||||
- Surface submit failures with a retryable alert (refs #68)
|
||||
- One citizen reference across self-service and the openbaar register (#79)
|
||||
|
||||
### Other
|
||||
- Openbaar Register portal — public lookup (#76)
|
||||
- Approval flow — temp admin endpoint + status transition to projection (#77)
|
||||
|
||||
### Refactor
|
||||
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
|
||||
- Use upstream images verbatim, seed config via docker cp (refs #30)
|
||||
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
|
||||
|
||||
### Tests
|
||||
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
|
||||
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
|
||||
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
|
||||
- Integration test opens a real zaak against OpenZaak (refs #46)
|
||||
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Ratchet projector mutation baseline to 100% (refs #7)
|
||||
- Registration aggregate invariants (refs #6)
|
||||
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
|
||||
- Workflow Client, ACL client, store and job processor (refs #6)
|
||||
- Acceptance scenario for submitting a registration (refs #6)
|
||||
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
|
||||
- Endpoints, JWT auth and public-safe projection (refs #8)
|
||||
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
|
||||
- Self-service portal placeholder renders (refs #65)
|
||||
- Generated BFF client is exposed and calls the endpoints (refs #66)
|
||||
- DigiD-guarded registration submit page (refs #67)
|
||||
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
|
||||
- Submit surfaces BFF failures instead of swallowing them (refs #68)
|
||||
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
|
||||
|
||||
|
||||
@@ -7,7 +7,22 @@
|
||||
|
||||
SLN := register-referentie.slnx
|
||||
COMPOSE := infra/docker-compose.yml
|
||||
HEALTH_URL := http://localhost:8080/health
|
||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||
# containerized CI runner. SEED populates them; run it before every `up`. The
|
||||
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
|
||||
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
SEED := bash infra/seed-config.sh
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
|
||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
||||
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
|
||||
# engine. This is the no-make / Windows-friendly path. See that file's header.
|
||||
LOCAL_COMPOSE := infra/docker-compose.local.yml
|
||||
OZ_COMPOSE := infra/openzaak/docker-compose.yml
|
||||
OZ_BASE := http://localhost:8000
|
||||
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
|
||||
@@ -28,10 +43,23 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
|
||||
ci: lint build unit smoke
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
ci: lint build unit mutation frontend verify
|
||||
|
||||
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
||||
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
|
||||
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
|
||||
# not configurable. When the ~5min production build shares the run-many pool, it
|
||||
# starves that worker of CPU on constrained CI runners and Vitest fails with
|
||||
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
|
||||
# heavy build's back so the worker starts well inside its window.
|
||||
frontend:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm nx run-many -t lint test
|
||||
pnpm nx run-many -t build
|
||||
|
||||
## lint: verify formatting (no changes)
|
||||
lint:
|
||||
@@ -41,30 +69,132 @@ lint:
|
||||
build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## unit: run unit tests
|
||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||
unit:
|
||||
dotnet test $(SLN) -c Release
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||
|
||||
## smoke: compose up (wait for healthy), curl /health, then tear down
|
||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
||||
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
|
||||
# Scores never regress below baseline.
|
||||
mutation:
|
||||
dotnet tool restore
|
||||
cd services/acl && dotnet stryker
|
||||
cd services/event-subscriber && dotnet stryker
|
||||
cd services/domain && dotnet stryker
|
||||
cd services/bff && dotnet stryker
|
||||
|
||||
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
|
||||
# SEED populates the external config volumes first (upstream images used verbatim;
|
||||
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
|
||||
# checked by infra/wait-healthy.sh polling the durable, health-checked services
|
||||
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
|
||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
||||
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||
smoke:
|
||||
docker compose -f $(COMPOSE) up -d --build --wait
|
||||
bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc'
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
|
||||
|
||||
## down: stop and remove the local stack
|
||||
## up: seed config volumes and start the full stack (use instead of bare
|
||||
## `docker compose up`, which can't self-seed the external config volumes)
|
||||
up:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
|
||||
## down: stop and remove the local stack (incl. the external config volumes)
|
||||
down:
|
||||
docker compose -f $(COMPOSE) down --volumes
|
||||
-docker volume rm -f $(CFG_VOLS)
|
||||
|
||||
## local: bring up the bind-mount stack (no seed step) and wait for health
|
||||
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
|
||||
local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
|
||||
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
|
||||
changelog:
|
||||
git-cliff --output CHANGELOG.md
|
||||
|
||||
# ── ZGW verification ───────────────────────────────────────────────────────
|
||||
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
|
||||
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
|
||||
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
|
||||
# check logic lives in stack-agnostic runners that reach services by container IP
|
||||
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
|
||||
# convenience wrappers that bring up a lighter stack and call the same runners.
|
||||
|
||||
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
|
||||
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
|
||||
verify-up:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
|
||||
verify-acl:
|
||||
bash infra/run-acl-integration.sh
|
||||
|
||||
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
|
||||
verify-nrc:
|
||||
bash infra/run-notification-check.sh
|
||||
|
||||
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
|
||||
## against the already-running stack.
|
||||
verify-projection:
|
||||
bash infra/run-projection-check.sh
|
||||
|
||||
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
|
||||
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
|
||||
verify-domain:
|
||||
bash infra/run-domain-check.sh
|
||||
|
||||
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
|
||||
## + anonymous public-safe openbaar register (ADR-0010).
|
||||
verify-bff:
|
||||
bash infra/run-bff-check.sh
|
||||
|
||||
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
|
||||
## submit → confirmation, driven inside the compose network.
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||
## tear down (always). For fast single-concern local iteration use `integration`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
verify:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
@bash -c 'set -e; rc=0; \
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||
&& bash infra/run-acl-integration.sh \
|
||||
&& bash infra/run-notification-check.sh \
|
||||
&& bash infra/run-projection-check.sh \
|
||||
&& bash infra/run-domain-check.sh \
|
||||
&& bash infra/run-bff-check.sh \
|
||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
||||
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
||||
exit $$rc'
|
||||
|
||||
## integration: local convenience — ACL integration test against a throwaway
|
||||
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
|
||||
integration:
|
||||
bash infra/run-integration.sh
|
||||
|
||||
## openzaak-up: start the OpenZaak stack (migrations run on first start)
|
||||
openzaak-up:
|
||||
$(SEED) oz
|
||||
docker compose -f $(OZ_COMPOSE) up -d
|
||||
|
||||
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
|
||||
openzaak-smoke:
|
||||
docker compose -f $(OZ_COMPOSE) up -d
|
||||
openzaak-smoke: openzaak-up
|
||||
@bash -c 'set -e; \
|
||||
echo "waiting for OpenZaak to respond..."; \
|
||||
for i in $$(seq 1 60); do \
|
||||
@@ -88,10 +218,18 @@ openzaak-seed: openzaak-up
|
||||
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
|
||||
openzaak-down:
|
||||
docker compose -f $(OZ_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-oz-config
|
||||
|
||||
## stack-up: start OpenZaak + Open Notificaties together (shared network)
|
||||
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
|
||||
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
|
||||
verify-notifications:
|
||||
bash infra/verify-notifications.sh
|
||||
|
||||
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
|
||||
## OpenZaak publishing notifications to NRC (S-01-c).
|
||||
stack-up:
|
||||
docker compose $(STACK_FILES) up -d
|
||||
$(SEED) oz nrc
|
||||
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
|
||||
|
||||
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
|
||||
stack-smoke: stack-up
|
||||
@@ -110,9 +248,11 @@ stack-smoke: stack-up
|
||||
## stack-down: stop and remove both stacks (wipes data)
|
||||
stack-down:
|
||||
docker compose $(STACK_FILES) down --volumes
|
||||
-docker volume rm -f rr-oz-config rr-nrc-config
|
||||
|
||||
## keycloak-up: start Keycloak with the four imported realms
|
||||
keycloak-up:
|
||||
$(SEED) kc
|
||||
docker compose -f $(KC_COMPOSE) up -d
|
||||
|
||||
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
|
||||
@@ -125,9 +265,11 @@ keycloak-smoke: keycloak-up
|
||||
## keycloak-down: stop and remove Keycloak
|
||||
keycloak-down:
|
||||
docker compose -f $(KC_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-kc-realms
|
||||
|
||||
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
|
||||
flowable-up:
|
||||
$(SEED) fl
|
||||
docker compose -f $(FL_COMPOSE) up -d
|
||||
|
||||
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
|
||||
@@ -140,6 +282,7 @@ flowable-smoke: flowable-up
|
||||
## flowable-down: stop and remove Flowable
|
||||
flowable-down:
|
||||
docker compose -f $(FL_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-fl-bpmn
|
||||
|
||||
## help: list available targets
|
||||
help:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Multi-stage build for the behandel 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/behandel apps/behandel
|
||||
COPY libs libs
|
||||
RUN pnpm nx build behandel
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist/apps/behandel/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 medewerker authority (host-consistent, ADR-0013).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
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 behandel 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, and the
|
||||
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
||||
location /behandel/ {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "behandel",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"sourceRoot": "apps/behandel/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular/build:application",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/behandel",
|
||||
"browser": "apps/behandel/src/main.ts",
|
||||
"tsConfig": "apps/behandel/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/behandel/public"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/behandel/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": "behandel:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "behandel: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": "behandel:build",
|
||||
"staticFilePath": "dist/apps/behandel/browser",
|
||||
"spa": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/medewerker"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,73 @@
|
||||
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 medewerker 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 makes the relative URL never match, so
|
||||
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||
describe('behandel medewerker token wiring', () => {
|
||||
let http: HttpTestingController;
|
||||
let bff: BffApiV1Service;
|
||||
const token = 'medewerker-access-token';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ConfigurationService,
|
||||
useValue: {
|
||||
hasAtLeastOneConfig: () => true,
|
||||
getAllConfigurations: () => [{ configId: 'medewerker', 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 werkbak call', () => {
|
||||
bff.getBehandelWerkbak().subscribe();
|
||||
|
||||
const req = http.expectOne('/behandel/werkbak');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('attaches the bearer token to the relative decide call', () => {
|
||||
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
|
||||
|
||||
const req = http.expectOne('/behandel/registrations/reg-1/decide');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
req.flush(null);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||
export interface RuntimeConfig {
|
||||
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||
authority: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route prefixes whose requests carry the medewerker 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. Only `/behandel/` is secured; the app calls no other endpoint group.
|
||||
*/
|
||||
export const SECURE_API_ROUTES = ['/behandel/'];
|
||||
|
||||
/**
|
||||
* 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()])),
|
||||
provideMedewerkerAuth({
|
||||
authority: runtime.authority,
|
||||
redirectUrl: origin,
|
||||
secureRoutes: SECURE_API_ROUTES,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { WerkbakPage } from './werkbak/werkbak-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
||||
];
|
||||
@@ -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 WerkbakPage owns the heading).
|
||||
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||
expect(screen).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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 = 'behandel';
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Werkbak</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
|
||||
opnieuw.
|
||||
</p>
|
||||
} @else if (loaded() && items().length === 0) {
|
||||
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
|
||||
} @else if (items().length > 0) {
|
||||
<table utrecht-table>
|
||||
<caption>
|
||||
Registraties in behandeling
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Referentie</th>
|
||||
<th scope="col">BSN</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of items(); track item.registrationId) {
|
||||
<tr>
|
||||
<td>{{ item.registrationId }}</td>
|
||||
<td>{{ item.bsn }}</td>
|
||||
<td>{{ item.status }}</td>
|
||||
<td>
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="primary-action-button"
|
||||
type="button"
|
||||
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
|
||||
[disabled]="deciding() === item.registrationId"
|
||||
(click)="decide(item.registrationId, 'goedkeuren')"
|
||||
>
|
||||
Goedkeuren
|
||||
</button>
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="secondary-action-button"
|
||||
type="button"
|
||||
[attr.aria-label]="'Afwijzen ' + item.registrationId"
|
||||
[disabled]="deciding() === item.registrationId"
|
||||
(click)="decide(item.registrationId, 'afwijzen')"
|
||||
>
|
||||
Afwijzen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { WerkbakPage } from './werkbak-page';
|
||||
|
||||
const sample: WerkbakItem[] = [
|
||||
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
||||
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
|
||||
];
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['behandelaar']);
|
||||
login(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
logout(): void {
|
||||
/* spied in tests */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
|
||||
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBehandelWerkbak =
|
||||
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
|
||||
const postBehandelRegistrationsIdDecide =
|
||||
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBehandelWerkbak,
|
||||
postBehandelRegistrationsIdDecide,
|
||||
providers: [
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
|
||||
},
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('WerkbakPage', () => {
|
||||
it('lists the registrations awaiting beoordeling on open', async () => {
|
||||
const { getBehandelWerkbak, providers } = setup();
|
||||
await render(WerkbakPage, { providers });
|
||||
|
||||
expect(getBehandelWerkbak).toHaveBeenCalled();
|
||||
expect(await screen.findByText('reg-1')).toBeTruthy();
|
||||
expect(screen.getByText('123456782')).toBeTruthy();
|
||||
expect(screen.getByText('reg-2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
|
||||
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
|
||||
await render(WerkbakPage, { providers });
|
||||
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
|
||||
|
||||
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||
besluit: 'goedkeuren',
|
||||
});
|
||||
// Reloaded after the decision: once on open, once after deciding.
|
||||
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
|
||||
const { postBehandelRegistrationsIdDecide, providers } = setup();
|
||||
await render(WerkbakPage, { providers });
|
||||
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
|
||||
|
||||
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||
besluit: 'afwijzen',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an empty state when the werkbak has no items', async () => {
|
||||
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
||||
await render(WerkbakPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a load failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(WerkbakPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(WerkbakPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
||||
type Besluit = 'goedkeuren' | 'afwijzen';
|
||||
|
||||
/**
|
||||
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
|
||||
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
||||
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
||||
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-werkbak-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './werkbak-page.html',
|
||||
})
|
||||
export class WerkbakPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly items = signal<WerkbakItem[]>([]);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly deciding = signal<string | undefined>(undefined);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.bff.getBehandelWerkbak().subscribe({
|
||||
next: (rows: WerkbakItem[]) => {
|
||||
this.items.set(rows);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||
error: () => {
|
||||
this.items.set([]);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
decide(registrationId: string, besluit: Besluit): void {
|
||||
this.deciding.set(registrationId);
|
||||
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
|
||||
// Refresh so the decided registration drops off the werkbak (its task is now completed).
|
||||
next: () => {
|
||||
this.deciding.set(undefined);
|
||||
this.load();
|
||||
},
|
||||
error: () => {
|
||||
this.deciding.set(undefined);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Behandelportaal 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>
|
||||
@@ -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));
|
||||
@@ -0,0 +1,2 @@
|
||||
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||
@import '@utrecht/design-tokens/dist/index.css';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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).
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { RegisterPage } from './register/register-page';
|
||||
|
||||
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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.reference }}</td>
|
||||
<td>{{ entry.status }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,59 @@
|
||||
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', reference: 'REG-abc' },
|
||||
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||
];
|
||||
|
||||
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();
|
||||
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
|
||||
// not the internal zaak id.
|
||||
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
|
||||
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
|
||||
expect(screen.getByText(/REG-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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -0,0 +1,2 @@
|
||||
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||
@import '@utrecht/design-tokens/dist/index.css';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||
#
|
||||
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||
#
|
||||
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||
# entrypoint executes or sources it.
|
||||
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||
echo "portal-nginx-resolver: set resolver to $ns"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||
|
||||
EXPOSE 80
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/digid"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
@@ -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] },
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||
|
||||
@if (submitted()) {
|
||||
@if (withdrawn()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||
</p>
|
||||
} @else {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@if (documentsProvided()) {
|
||||
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||
} @else {
|
||||
@if (provideDocumentsFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
|
||||
<label utrecht-form-label for="diploma">Diploma</label>
|
||||
<input
|
||||
id="diploma"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
[disabled]="providingDocuments()"
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="primary-action-button"
|
||||
type="button"
|
||||
[disabled]="providingDocuments() || !selectedFile()"
|
||||
(click)="provideDocuments()"
|
||||
>
|
||||
Documenten aanleveren
|
||||
</button>
|
||||
}
|
||||
@if (withdrawFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="secondary-action-button"
|
||||
type="button"
|
||||
[disabled]="withdrawing()"
|
||||
(click)="withdraw()"
|
||||
>
|
||||
Trek aanvraag in
|
||||
</button>
|
||||
}
|
||||
} @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>
|
||||
@@ -0,0 +1,154 @@
|
||||
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' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||
const { withdraw, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
|
||||
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
|
||||
|
||||
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
|
||||
const { provideDocuments, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
// Choose the file, then upload it.
|
||||
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
|
||||
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||
expect(provideDocuments).toHaveBeenCalledWith(
|
||||
'reg-9',
|
||||
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a diploma-upload failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(of(undefined)),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /trek aanvraag in/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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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). After
|
||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||
*/
|
||||
@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);
|
||||
protected readonly withdrawing = signal(false);
|
||||
protected readonly withdrawn = signal(false);
|
||||
protected readonly withdrawFailed = signal(false);
|
||||
protected readonly providingDocuments = signal(false);
|
||||
protected readonly documentsProvided = signal(false);
|
||||
protected readonly provideDocumentsFailed = signal(false);
|
||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.selectedFile.set(input.files?.[0] ?? undefined);
|
||||
}
|
||||
|
||||
async provideDocuments(): Promise<void> {
|
||||
const reference = this.reference();
|
||||
const file = this.selectedFile();
|
||||
if (!reference || !file) {
|
||||
return;
|
||||
}
|
||||
this.providingDocuments.set(true);
|
||||
this.provideDocumentsFailed.set(false);
|
||||
let contentBase64: string;
|
||||
try {
|
||||
contentBase64 = await readAsBase64(file);
|
||||
} catch {
|
||||
this.provideDocumentsFailed.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
return;
|
||||
}
|
||||
this.bff
|
||||
.postSelfServiceRegistrationsIdDocuments(reference, {
|
||||
contentBase64,
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/pdf',
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.documentsProvided.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.provideDocumentsFailed.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
withdraw(): void {
|
||||
const reference = this.reference();
|
||||
if (!reference) {
|
||||
return;
|
||||
}
|
||||
this.withdrawing.set(true);
|
||||
this.withdrawFailed.set(false);
|
||||
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||
next: () => {
|
||||
this.withdrawn.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.withdrawFailed.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
||||
function readAsBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
|
||||
reader.readAsDataURL(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>
|
||||
@@ -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));
|
||||
@@ -0,0 +1,2 @@
|
||||
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||
@import '@utrecht/design-tokens/dist/index.css';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-25
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
|
||||
|
||||
## Context
|
||||
|
||||
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
|
||||
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
|
||||
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
|
||||
strictly, no slice could satisfy that gate. S-04b establishes it.
|
||||
|
||||
The ACL is the natural place to set the first baseline: it is the first service with real
|
||||
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
|
||||
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
|
||||
that:
|
||||
|
||||
- mutates C# and runs the existing xUnit suite per mutant,
|
||||
- is reproducible (same version locally and in CI, no global install),
|
||||
- understands this repo's `.slnx` solution format (used repo-wide),
|
||||
- emits a break threshold CI can gate on.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
|
||||
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
|
||||
|
||||
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
|
||||
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
|
||||
- **Solution mode** (`stryker-config.json` → `solution: Acl.slnx`) mutates the two projects
|
||||
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
|
||||
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
|
||||
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
|
||||
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
|
||||
|
||||
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
|
||||
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
|
||||
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
|
||||
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%** —
|
||||
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
|
||||
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
|
||||
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
|
||||
the small ACL). Pinned + tool-restored, so reproducible.
|
||||
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
|
||||
Asserting exception message text is brittle and the behaviour (type + control flow) is
|
||||
unchanged — treated as an equivalent mutant, not a test gap.
|
||||
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
|
||||
services add their own mutation run as they gain branching logic (BFF, Domain, …).
|
||||
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
|
||||
names; the fallback is no mutation testing, which §5 forbids.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
|
||||
local manifest gives every checkout and the CI runner the same version.
|
||||
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
|
||||
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
|
||||
opts in as it gains logic.
|
||||
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
|
||||
the riskiest code — unguarded by the ratchet.
|
||||
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
|
||||
tests would *catch* a regression; that is the whole point of §5.
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-0006: Provision the ACL integration test against the compose stack
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-29
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
|
||||
|
||||
## Context
|
||||
|
||||
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
|
||||
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
|
||||
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
|
||||
|
||||
> Integration test using Testcontainers against real OpenZaak passes.
|
||||
|
||||
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
|
||||
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
|
||||
|
||||
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
|
||||
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
|
||||
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
|
||||
|
||||
Investigation reversed the initially-favoured Testcontainers option:
|
||||
|
||||
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
|
||||
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
|
||||
mean re-implementing that five-service stack — init ordering, the config volume, health
|
||||
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
|
||||
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
|
||||
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
|
||||
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
|
||||
which OpenZaak validates by fetching the external **Selectielijst** reference API
|
||||
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
|
||||
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
|
||||
regardless of how the containers are started.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL integration test targets the running compose stack; it does not start containers
|
||||
itself. No new test dependency is added.**
|
||||
|
||||
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
|
||||
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
|
||||
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
|
||||
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
|
||||
`OpenZaakGateway` against it.
|
||||
- **The lane is kept out of the fast checks.** `make unit` runs with
|
||||
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
|
||||
neither the unit nor the mutation lane needs a live stack. A `make integration` target
|
||||
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
|
||||
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
|
||||
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
|
||||
source of truth (ADR-0005).
|
||||
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
|
||||
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
|
||||
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
|
||||
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
|
||||
a concept; only `make integration` flips the switch.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
|
||||
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
|
||||
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
|
||||
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
|
||||
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
|
||||
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
|
||||
test asserting a `Content-Length` is set. This is the concrete justification for §11's
|
||||
integration tier.
|
||||
- **External dependency:** the integration job needs the OpenZaak container to reach
|
||||
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
|
||||
in production) but it is a network touchpoint, and a CI environment without egress would need
|
||||
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
|
||||
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
|
||||
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
|
||||
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
|
||||
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
|
||||
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
|
||||
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
|
||||
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
|
||||
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
|
||||
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
|
||||
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
|
||||
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
|
||||
undermine the contract the test exists to verify.
|
||||
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
|
||||
tests already do; it cannot exercise the real contract, and would not have caught the chunked
|
||||
body bug.
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-29
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
|
||||
|
||||
## Context
|
||||
|
||||
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
|
||||
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
|
||||
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
|
||||
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
|
||||
subscribers — before the Event Subscriber (#7) can consume it.
|
||||
|
||||
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
|
||||
were nailed down by iterating `setup_configuration` against the running stack.
|
||||
|
||||
## Decision
|
||||
|
||||
**Provision both sides declaratively via `setup_configuration`, authenticate with the
|
||||
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
|
||||
|
||||
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
|
||||
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
|
||||
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present** —
|
||||
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
|
||||
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
|
||||
so they don't 500 publishing to an absent NRC.
|
||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
|
||||
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
|
||||
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
|
||||
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
|
||||
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
|
||||
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
|
||||
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
|
||||
daemon).
|
||||
- **celery-beat is required.** NRC accepts a notification and writes a
|
||||
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
|
||||
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
|
||||
S-01 stack dropped beat — so notifications were accepted but never delivered. An
|
||||
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
|
||||
|
||||
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
|
||||
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
|
||||
asserts the sink receives the `zaken`/`create` notification — all from containers
|
||||
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
|
||||
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
|
||||
`verify-stack` job (one shared full-stack bring-up — issue #58).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
|
||||
notifications; the wiring is declarative and reproducible from a fresh `make`.
|
||||
- **Gotchas captured (see gitea-actions-gotchas.md):**
|
||||
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
|
||||
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
|
||||
harness reaches services and registers the sink callback **by container IP**.
|
||||
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
|
||||
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
|
||||
without the configured `Authorization`; the sink enforces a bearer token.
|
||||
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
|
||||
needs egress (base images + `selectielijst.openzaak.nl`, since the published
|
||||
zaaktype the check creates a zaak against depends on it — ADR-0006).
|
||||
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
|
||||
AC lookup, and seeding — acceptable for the reference app, not production.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
|
||||
Autorisaties API is the upstream-intended model and reuses the applicatie that
|
||||
already grants `heeft_alle_autorisaties`.
|
||||
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
|
||||
delivers via scheduled notifications drained by beat; there is no sync path.
|
||||
- **A persistent abonnement in `setup_configuration`** instead of registering one in
|
||||
the verify harness — deferred: the real subscriber is #7; the harness's sink
|
||||
abonnement is throwaway and IP-specific.
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-30
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
|
||||
|
||||
## Context
|
||||
|
||||
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
|
||||
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
|
||||
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status` —
|
||||
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
|
||||
(a derived artefact, never a write-only source of truth).
|
||||
|
||||
Two design questions had no obvious answer:
|
||||
|
||||
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
|
||||
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
|
||||
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
|
||||
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
|
||||
not the ACL.
|
||||
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
|
||||
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
|
||||
schema." Two deployables on one table looks like a violation.
|
||||
|
||||
## Decision
|
||||
|
||||
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
|
||||
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
|
||||
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
|
||||
deferred.**
|
||||
|
||||
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
|
||||
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
|
||||
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
|
||||
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
|
||||
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
|
||||
holds for every domain database.
|
||||
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
|
||||
derived from the immutable notification content). A duplicate insert raises a unique violation,
|
||||
caught and reported as "already recorded", so the duplicate never reaches the projection. The
|
||||
projection upsert is itself idempotent on the zaak id, a second line of defence.
|
||||
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
|
||||
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
|
||||
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
|
||||
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
|
||||
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
|
||||
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
|
||||
is in place so that change is additive.
|
||||
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
|
||||
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
|
||||
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
|
||||
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
|
||||
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
|
||||
audited, shipped graph.
|
||||
|
||||
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
|
||||
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
|
||||
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
|
||||
in-network, reaching services by container IP (ADR-0006/0007).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
|
||||
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
|
||||
ready for S-09 to tighten public-safe field filtering.
|
||||
- **Negative / deferred:**
|
||||
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
|
||||
- The abonnement is registered by the verify harness (by container IP), not provisioned
|
||||
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
|
||||
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
|
||||
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
|
||||
path flows.
|
||||
- Two services share one database. Acceptable for a derived read model; revisit if the read and
|
||||
write sides ever need independent scaling or storage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
|
||||
to ZGW) and would need its own ADR to bend the rule.
|
||||
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
|
||||
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
|
||||
deferred to a follow-up.
|
||||
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
|
||||
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
|
||||
current benefit over a shared, rebuildable read model.
|
||||
- **Separate databases for the log and the projection** — rejected as premature: both are the read
|
||||
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.
|
||||
@@ -0,0 +1,88 @@
|
||||
# ADR-0009: The Domain Service drives Flowable as an external-task job worker
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-30
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation)
|
||||
|
||||
## Context
|
||||
|
||||
S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a
|
||||
`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the
|
||||
`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL
|
||||
back on the aggregate.
|
||||
|
||||
`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`,
|
||||
topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a
|
||||
worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain
|
||||
who may do what:
|
||||
|
||||
- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed
|
||||
OpenZaak knowledge; they ask the Workflow Client to execute external tasks.
|
||||
- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*,
|
||||
never by constructing ZGW URLs itself.
|
||||
|
||||
This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are
|
||||
exercised. The open question is *how* the external task is driven.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker
|
||||
(PRD §36). Orchestration is eventually consistent, not request-synchronous.**
|
||||
|
||||
- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the
|
||||
`Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start
|
||||
one `registratie` process instance, recording the process-instance id on the aggregate. It returns
|
||||
immediately; it does **not** wait for the zaak to be opened.
|
||||
- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls
|
||||
the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate
|
||||
(`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end
|
||||
event.
|
||||
- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's
|
||||
`Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete
|
||||
external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable
|
||||
exists.
|
||||
- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker`
|
||||
takes an acquired job (topic + the registration id it carries), calls `IAclClient` and
|
||||
`IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin
|
||||
`BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to
|
||||
the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing
|
||||
needs a container integration test.
|
||||
|
||||
## Scope decisions for the minimal slice
|
||||
|
||||
- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by
|
||||
NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain
|
||||
store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore`
|
||||
port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.)
|
||||
- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal,
|
||||
beoordeling, herregistratie) add states in their own slices — they are out of scope here.
|
||||
- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory
|
||||
fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common
|
||||
Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the
|
||||
Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable.
|
||||
- **Negative / deferred:**
|
||||
- Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet.
|
||||
Acceptable — the read side is the projection, not the domain store.
|
||||
- In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store
|
||||
in a follow-up.
|
||||
- The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since
|
||||
Flowable holds the job until completed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and
|
||||
deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the
|
||||
external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is
|
||||
briefly down, instead of the job simply staying parked for the worker to retry.
|
||||
- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this
|
||||
slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places
|
||||
the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a
|
||||
shared store for no current benefit.
|
||||
- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker
|
||||
model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker
|
||||
delivery guarantees.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,104 @@
|
||||
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
|
||||
|
||||
## Context
|
||||
|
||||
A citizen submits through the self-service portal and is shown a confirmation with a
|
||||
**reference** so they can find their registration back in the public register. But the two
|
||||
sides showed **different identifiers**:
|
||||
|
||||
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
|
||||
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
|
||||
exists.
|
||||
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
|
||||
assigned by OpenZaak when the ACL opens the zaak.
|
||||
|
||||
These never match, so the reference on the confirmation was useless for looking the entry up.
|
||||
The two identifiers live on opposite sides of the ACL boundary and are generated by different
|
||||
systems at different times, so there is no way to reconcile them after the fact without a
|
||||
correlating value carried across the boundary.
|
||||
|
||||
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
|
||||
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
|
||||
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
|
||||
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
|
||||
This is that follow-up, scoped to the one field the citizen actually needs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
|
||||
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
|
||||
with the reference by reading the zaak through the ACL, and stores it in the replay log so
|
||||
rebuild stays log-only.**
|
||||
|
||||
Concretely, following the request path:
|
||||
|
||||
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
|
||||
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
|
||||
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
|
||||
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
|
||||
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
|
||||
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
|
||||
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
|
||||
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
|
||||
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
|
||||
3. **Projection + replay log.** The reference is written both to the `register_projection` row
|
||||
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
|
||||
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
|
||||
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
|
||||
without re-reading the ACL.
|
||||
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
|
||||
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
|
||||
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
|
||||
|
||||
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
|
||||
from the submit confirmation must appear as a cell in the public register.
|
||||
|
||||
### Why HTTP to the ACL, not the ACL as a library
|
||||
|
||||
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
|
||||
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
|
||||
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
|
||||
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
|
||||
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One reference, end to end: the citizen's confirmation value is exactly what the public
|
||||
register shows and searches by.
|
||||
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
|
||||
OpenZaak.
|
||||
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
|
||||
so `/admin/rebuild` needs no ACL/ZGW access.
|
||||
- The column additions are nullable and additive; older rows without a reference are tolerated.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A new coupling: the Event Subscriber now depends on the ACL being reachable
|
||||
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
|
||||
need the notification redelivered (NRC already redelivers; the projection upsert is
|
||||
idempotent).
|
||||
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
|
||||
one small GET per zaak, off the citizen's request path.
|
||||
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
|
||||
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
|
||||
setting it explicitly is now load-bearing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
|
||||
the notification content is not ours to extend; it would also couple the projection to a
|
||||
bespoke notification shape.
|
||||
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
|
||||
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
|
||||
no zaak id to show at submit time.
|
||||
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
|
||||
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
|
||||
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
|
||||
source of truth for a value that can simply be the same on both sides.
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-15
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
|
||||
|
||||
## Context
|
||||
|
||||
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
|
||||
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
|
||||
the whole slice.
|
||||
|
||||
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
|
||||
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
|
||||
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
|
||||
`digid`-realm JWT validation rejects a medewerker token outright.
|
||||
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
|
||||
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
|
||||
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
|
||||
task; the decision must advance it, and also apply the domain transition (ADR-0011).
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
|
||||
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
|
||||
completes the Flowable task.**
|
||||
|
||||
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
|
||||
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
|
||||
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
|
||||
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
|
||||
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
|
||||
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
|
||||
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
|
||||
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
|
||||
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
|
||||
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
|
||||
decision acts on a real task with no separate correlation store. The read projection stays the
|
||||
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
|
||||
data (both deferred in ADR-0008) just to render a staff view.
|
||||
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
|
||||
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
|
||||
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
|
||||
here so the boundary is decided up front.
|
||||
|
||||
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
|
||||
endpoint + task completion.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
|
||||
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
|
||||
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
|
||||
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
|
||||
(§8.3).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
|
||||
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
|
||||
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
|
||||
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
|
||||
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
|
||||
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
|
||||
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
|
||||
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
|
||||
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
|
||||
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
|
||||
enough at this size and keeps §8.3 simple.
|
||||
@@ -0,0 +1,72 @@
|
||||
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-16
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
|
||||
(behandel-portal wiring, the Beoordelen user task)
|
||||
|
||||
## Context
|
||||
|
||||
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
|
||||
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
|
||||
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
|
||||
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
|
||||
|
||||
Two questions shape this sub-slice.
|
||||
|
||||
1. **How does the case get cancelled — in code, or in the BPMN model?**
|
||||
2. **How does a withdrawal correlate to the right running process instance?**
|
||||
|
||||
## Decision
|
||||
|
||||
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
|
||||
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
|
||||
|
||||
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
|
||||
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
|
||||
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
|
||||
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
|
||||
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
|
||||
(the process ended via the ingetrokken path, not a raw delete).
|
||||
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
|
||||
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
|
||||
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
|
||||
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
|
||||
Targeting the subscribed execution (not the user task's execution — a message boundary event's
|
||||
subscription lives on its own execution) is what makes the correlation land.
|
||||
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
|
||||
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
|
||||
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
|
||||
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
|
||||
completes its task best-effort.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
|
||||
that talks to Flowable and does not delete instances behind the model's back.
|
||||
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
|
||||
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
|
||||
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
|
||||
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
|
||||
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
|
||||
reference at these volumes; a process-level interrupting event subprocess would close the gap and
|
||||
is an additive follow-up if it matters.
|
||||
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
|
||||
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
|
||||
(consistent with ADR-0009).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
|
||||
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
|
||||
not the process. The message event keeps the cancellation a first-class part of the workflow.
|
||||
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
|
||||
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
|
||||
change if the race proves to matter.
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-17
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
|
||||
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
|
||||
pattern on `Beoordelen`).
|
||||
|
||||
## Context
|
||||
|
||||
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
|
||||
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
|
||||
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
|
||||
|
||||
Two forces shape this.
|
||||
|
||||
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
|
||||
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
|
||||
escalation happens alongside it.
|
||||
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
|
||||
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
|
||||
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
|
||||
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
|
||||
(§8.2)" posture since ADR-0009.
|
||||
|
||||
## Decision
|
||||
|
||||
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
|
||||
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
|
||||
behandelaar group to teamlead.**
|
||||
|
||||
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
|
||||
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
|
||||
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
|
||||
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
|
||||
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
|
||||
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
|
||||
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
|
||||
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
|
||||
belongs to the teamlead; its history and variables are untouched.
|
||||
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
|
||||
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
|
||||
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
|
||||
`OpenZaakAanmaken` worker.
|
||||
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
|
||||
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
|
||||
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
|
||||
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
|
||||
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
|
||||
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
|
||||
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
|
||||
timer.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
|
||||
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
|
||||
on redelivery.
|
||||
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
|
||||
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
|
||||
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
|
||||
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
|
||||
surface to the engine for no capability the external-worker route lacks.
|
||||
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
|
||||
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
|
||||
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-17
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
|
||||
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
|
||||
|
||||
## Context
|
||||
|
||||
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
|
||||
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
|
||||
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
|
||||
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
|
||||
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
|
||||
governing the registration workflow" (Flowable as a peer orchestration module).
|
||||
|
||||
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
|
||||
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
|
||||
back. That is one option; it is not the only one, and it is not the cleanest.
|
||||
|
||||
## Decision
|
||||
|
||||
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
|
||||
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
|
||||
only new job is to carry the diploma origin and pass it into the process as a start variable.**
|
||||
|
||||
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
|
||||
→ `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
|
||||
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
|
||||
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
|
||||
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
|
||||
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
|
||||
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
|
||||
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
|
||||
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
|
||||
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
|
||||
never evaluates the DMN and never learns the route — that is the process's concern.
|
||||
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
|
||||
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
|
||||
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
|
||||
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
|
||||
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
|
||||
— the resource is stored but no decision is created, so the service task fails at runtime with
|
||||
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
|
||||
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
|
||||
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
|
||||
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
|
||||
With the decision present in the DMN repository, the process's DMN service task resolves it across
|
||||
deployments by key (verified live), so no shared parent deployment id is needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
|
||||
framing); business users can read/adjust the decision table without touching domain code.
|
||||
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
|
||||
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
|
||||
- The domain change is minimal and additive: one value on the aggregate, one start variable.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
|
||||
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
|
||||
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
|
||||
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
|
||||
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
|
||||
cover only that the origin is carried into the process.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
|
||||
the domain to Flowable for a decision and splits the routing across two places (domain computes,
|
||||
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
|
||||
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
|
||||
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
|
||||
@@ -0,0 +1,90 @@
|
||||
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
|
||||
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
|
||||
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
|
||||
|
||||
## Context
|
||||
|
||||
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
|
||||
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
|
||||
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
|
||||
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
|
||||
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
|
||||
aggregate stays in sync.
|
||||
|
||||
## Decision
|
||||
|
||||
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
|
||||
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
|
||||
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
|
||||
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
|
||||
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
|
||||
|
||||
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
|
||||
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
|
||||
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
|
||||
between the gateway and Beoordelen.
|
||||
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
|
||||
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
|
||||
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
|
||||
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
|
||||
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
|
||||
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
|
||||
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
|
||||
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
|
||||
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
|
||||
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
|
||||
without persisting again; an unknown registration throws so the job is redelivered.
|
||||
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
|
||||
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
|
||||
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
|
||||
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
|
||||
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
|
||||
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
|
||||
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
|
||||
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
|
||||
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
|
||||
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
|
||||
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
|
||||
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
|
||||
behind the same action.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
|
||||
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
|
||||
worker trio (worker + processor + pump), one Workflow Client method.
|
||||
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
|
||||
introduced in S-10a.
|
||||
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
|
||||
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
|
||||
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
|
||||
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
|
||||
through the process.
|
||||
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
|
||||
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
|
||||
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
|
||||
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
|
||||
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
|
||||
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
|
||||
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
|
||||
status — the case would still look open.
|
||||
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
|
||||
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
|
||||
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
|
||||
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
|
||||
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
|
||||
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
|
||||
expiry to #106 (S-10c).
|
||||
|
||||
## Context
|
||||
|
||||
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
|
||||
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
|
||||
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
|
||||
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
|
||||
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
|
||||
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
|
||||
zaaktype.**
|
||||
|
||||
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
|
||||
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
|
||||
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
|
||||
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers** —
|
||||
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
|
||||
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
|
||||
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
|
||||
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
|
||||
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
|
||||
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
|
||||
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
|
||||
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
|
||||
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
|
||||
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
|
||||
ZGW `inhoud`.
|
||||
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
|
||||
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
|
||||
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
|
||||
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
|
||||
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
|
||||
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
|
||||
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
|
||||
(a zeros-uuid placeholder otherwise, so the ACL still boots).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
|
||||
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
|
||||
default, one seed block).
|
||||
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
|
||||
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
|
||||
Playwright journey uploads a real PDF).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
|
||||
diploma, but not a pattern to reuse for large documents without streaming/multipart.
|
||||
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
|
||||
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
|
||||
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
|
||||
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
|
||||
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
|
||||
client, for no benefit at diploma size.
|
||||
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
|
||||
talks to ZGW).
|
||||
@@ -1,46 +0,0 @@
|
||||
# FDS-architectuur — Open Register
|
||||
|
||||
Deze map bevat de architectuurbesluiten en de engineer-documentatie voor de FDS-kant van deze
|
||||
referentie-applicatie: deelnemen aan het Federatief Datastelsel als **afnemer**.
|
||||
|
||||
De strategische inzet, de slices en de portfoliostatus staan in het Innovation Lab-repo,
|
||||
`Respellion/innovation-lab`, onder `projects/open-register-fd/`. Daar staan ook de
|
||||
architectuurblauwdruk, de FDS gap-analyse en de privacy-views.
|
||||
|
||||
## Documenten
|
||||
|
||||
| Document | Waarvoor |
|
||||
|---|---|
|
||||
| [`c4-component-view.md`](c4-component-view.md) | Componentview op niveau 3: ports en adapters, en welke views nog waarde toevoegen |
|
||||
| [`slice-1-proposal.md`](slice-1-proposal.md) | Het bouwbare eerste increment; plak dit in een `poc-voorstel`-issue |
|
||||
| `adr/` | De geaccepteerde architectuurbesluiten, ADR-0001 tot en met ADR-0006. Zie de tabel hieronder. |
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Een ADR legt een besluit vast dat **vaststaat**, met de context en de gevolgen, zodat het niet stil
|
||||
opnieuw wordt uitgevochten. Statuswaarden: `proposed` → `accepted` → (`vervangen door ADR-NNNN` |
|
||||
`deprecated`).
|
||||
|
||||
Een geaccepteerde ADR wijzigen betekent een nieuwe ADR schrijven die de oude vervangt. Wij
|
||||
herschrijven de historie nooit.
|
||||
|
||||
ADRs liggen naast governance. Acceptatie volgt de asynchrone bezwaarronde uit
|
||||
`Respellion/innovation-lab`, `operating-model/operating-model.md`, sectie *Besluitvorming*.
|
||||
|
||||
| ADR | Besluit | Status |
|
||||
|---|---|---|
|
||||
| [0001](adr/0001-acl-at-every-register-boundary.md) | Anti-Corruption Layer op elke registergrens | accepted |
|
||||
| [0002](adr/0002-fsc-for-connectivity.md) | FSC voor connectiviteit tussen organisaties, geen ruwe REST | accepted |
|
||||
| [0003](adr/0003-pbac-via-opa.md) | Policy-based access control via OPA, FTV-klaar | accepted |
|
||||
| [0004](adr/0004-bounded-cache.md) | Begrensde cache; registers blijven systeem van registratie | accepted |
|
||||
| [0005](adr/0005-ldv-verwerkingenlog.md) | Verwerkingenlog via event-emissie, in lijn met LDV | accepted |
|
||||
| [0006](adr/0006-module-boundary-and-reuse.md) | Modulegrens en hergebruikstrategie: in-process → .NET-module → OpenMetadata-feed → gateway op verzoek | accepted |
|
||||
|
||||
## Nummering
|
||||
|
||||
Deze reeks staat los van de ADR-reeks over de referentie-applicatie zelf, die begint bij
|
||||
[`adr-0001-loose-coupling.md`](../adr-0001-loose-coupling.md). Vandaar de eigen map `fds/`: beide
|
||||
reeksen beginnen bij 0001, en de nummers zouden anders botsen.
|
||||
|
||||
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
|
||||
pull request.
|
||||
@@ -1,42 +0,0 @@
|
||||
# ADR-0001: Anti-Corruption Layer op elke registergrens
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle (Build, Lead Link)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
De applicatie bevraagt meerdere registers: BRP, NHR/KVK, en ZGW via OpenZaak. Hun vocabulaires en
|
||||
schema's verschillen van elkaar en van ons domein. Zij veranderen ook zelf mee met de FDS-standaarden.
|
||||
|
||||
Lekt registervocabulaire het domeinmodel in, dan werkt elke wijziging aan de registerzijde door in de
|
||||
bedrijfslogica. Het domein wordt dan een lappendeken van vreemde begrippen in plaats van ubiquitous
|
||||
language.
|
||||
|
||||
## Besluit
|
||||
|
||||
Elk register is bereikbaar via een Anti-Corruption Layer: **één adapter per register**, die een
|
||||
**port** vervult die het domein definieert.
|
||||
|
||||
Adapters doen alleen vertalen en velden versmallen. Zij bevatten geen bedrijfslogica. Het domein
|
||||
spreekt `Persoon` en `Organisatie`, en nooit veldnamen uit BRP of NHR.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** verloop in registers en FDS-standaarden blijft bij de adapter. Het domein blijft stabiel
|
||||
en testbaar. Adapters zijn onafhankelijk vervangbaar, en dat is precies wat de FSC-wissel uit
|
||||
ADR-0002 goedkoop maakt. Het patroon generaliseert naar een herbruikbare ACL-template per register,
|
||||
een Foundations-kandidaat.
|
||||
|
||||
**Negatief en kosten:** één vertaalmap per register om te schrijven en te onderhouden, plus een extra
|
||||
indirectie die engineers moeten respecteren in plaats van omzeilen.
|
||||
|
||||
**Vervolgwerk:** extraheer de ACL-template zodra de tweede adapter bestaat (slice 3).
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Registers direct aanroepen uit de applicatieservices** — afgewezen: dit koppelt bedrijfscode aan
|
||||
registerschema's en aan versies van FDS-standaarden.
|
||||
- **Eén generieke registeradapter** — afgewezen: registers verschillen genoeg dat een generieke
|
||||
abstractie zou gaan lekken of opzwellen. Adapters per register zijn duidelijker.
|
||||
@@ -1,44 +0,0 @@
|
||||
# ADR-0002: FSC voor connectiviteit tussen organisaties, geen ruwe REST
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, Upstream Liaison
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
Registerbevragingen kruisen een organisatiegrens naar systemen van bronhouders met
|
||||
persoonsgegevens. Het FDS noemt Federatieve Service Connectiviteit (FSC, de opvolger van NLX) als de
|
||||
richting voor connectiviteit: wederzijdse authenticatie op organisatieniveau, autorisatie
|
||||
gecontroleerd tegen een contract en gehandhaafd bij de bron, en symmetrische transactielogging.
|
||||
|
||||
Een ruwe REST-client met mTLS geeft ons geen van de contractadministratie, delegatie of onafhankelijke
|
||||
tweezijdige verantwoording die een FG of auditor nodig heeft.
|
||||
|
||||
## Besluit
|
||||
|
||||
Het FSC Client-component stuurt alle registerbevragingen via een **FSC outway**, de
|
||||
EUPL-referentie-implementatie. De ACL-adapter hangt af van de FSC Client, en niet van een HTTP-client.
|
||||
|
||||
FSC-zaken — contracten, identiteiten, delegatie — leven in dit component, achter de Register Port.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** de autorisatie wordt bij de bron gehandhaafd, en niet op gezag van de aanroeper
|
||||
vertrouwd. Onweerlegbaar loggen aan beide uiteinden maakt onafhankelijke afstemming tegen ons LDV-log
|
||||
mogelijk. Delegatie wordt expliciet meegedragen. Wij lopen in lijn met de FDS-richting, vóór er een
|
||||
verplichting is.
|
||||
|
||||
**Negatief en kosten:** FSC is operationeel zwaarder dan een REST-aanroep — beheer van certificaten en
|
||||
identiteiten, plus een outway die op De Werf moet draaien. De vergelijking FSC tegenover DSP loopt
|
||||
binnen het FDS nog, dus sommige details kunnen schuiven.
|
||||
|
||||
**Vervolgwerk:** valideer het contract- en logginggedrag van de huidige fsc-nlx-implementatie
|
||||
(slice 2). Herzie dit als het FDS voor DSP kiest; ADR-0001 houdt die wissel beperkt tot één component.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Ruwe REST met mTLS** — afgewezen: geen contractlaag, geen tweezijdig log, en het wijkt af van het
|
||||
FDS.
|
||||
- **Wachten tot het FDS FSC tegenover DSP heeft beslist** — afgewezen: de naad uit ADR-0001 laat ons nu
|
||||
adopteren en later aanpassen. Wachten geeft het voordeel van vroege expertise weg.
|
||||
@@ -1,44 +0,0 @@
|
||||
# ADR-0003: Policy-based access control via OPA, FTV-klaar
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
Elke bevraging van persoonsgegevens uit BRP of NHR is een verwerking die een grondslag en een
|
||||
begrensde doelbinding nodig heeft. Toegangsregels moeten handhaafbaar en auditeerbaar zijn, en
|
||||
wijzigbaar zonder de bedrijfscode opnieuw uit te rollen.
|
||||
|
||||
De Federatieve Toegangsverlening (FTV) van het FDS beweegt naar policy-based access control, maar is
|
||||
nog geen afgeronde standaard.
|
||||
|
||||
## Besluit
|
||||
|
||||
Introduceer een Policy Decision Point met Open Policy Agent (OPA). De applicatieservices roepen de
|
||||
PDP aan — via een Authorisation Port en een PDP Client — **vóór elke registerbevraging**, en geven
|
||||
rol, doel en grondslag mee.
|
||||
|
||||
Policies schrijven wij als code, **geversioneerd in Gitea**, en zij gaan via review naar productie. De
|
||||
PDP staat zo gepositioneerd dat wij bij de komst van FTV alleen het policy-dialect opnieuw uitdrukken,
|
||||
zonder de architectuurgrens te verplaatsen.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** doelbinding en grondslag worden gehandhaafd, en niet alleen gedocumenteerd. De FG kan de
|
||||
werkelijke regels in versiebeheer lezen, waardoor het verwerkingenregister en de gehandhaafde policy
|
||||
naar elkaar toe groeien. Toegangswijzigingen zijn reviewbaar en gedateerd.
|
||||
|
||||
**Negatief en kosten:** BRP-autorisatiebesluiten correct modelleren is juridisch werk, geen
|
||||
engineering. De PDP maakt de handhaving betrouwbaar, niet de policy juist. Daarnaast komt er een
|
||||
component bij om te exploiteren.
|
||||
|
||||
**Vervolgwerk:** een promotiepijplijn voor policies in Gitea Actions. Policies opnieuw uitdrukken zodra
|
||||
FTV stabiliseert. Een FG-review van de policy-set vóórdat er echte persoonsgegevens in komen.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Rolcontroles in de applicatiecode** — afgewezen: niet auditeerbaar, niet wijzigbaar zonder deploy,
|
||||
en het verspreidt toegangslogica over de codebase.
|
||||
- **Wachten op FTV** — afgewezen: de PBAC-vorm is al duidelijk. Nu OPA, later het FTV-dialect.
|
||||
@@ -1,48 +0,0 @@
|
||||
# ADR-0004: Begrensde cache; registers blijven systeem van registratie
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
*Data bij de bron* verbiedt het behandelen van registerdata als lokale bron van waarheid. Maar BRP of
|
||||
NHR bij elke interactie bevragen is onpraktisch en vergroot de blootstelling.
|
||||
|
||||
Persoonsgegevens zijn de data die wij het minst willen opbouwen. Een onbegrensde cache wordt stil een
|
||||
schaduwregister, met een onbeheerde bewaarverplichting als gevolg.
|
||||
|
||||
## Besluit
|
||||
|
||||
Een **begrensde cache** staat achter een Cache Port, beheerd door een Cache Manager. Vier grenzen
|
||||
gelden.
|
||||
|
||||
| Grens | Wat die betekent |
|
||||
|---|---|
|
||||
| **Tijd** | Een TTL die aan het doel hangt |
|
||||
| **Omvang** | Alleen de werkset van een actieve zaak |
|
||||
| **Gezag** | Antwoordt nooit wat de bron niet zou antwoorden; geen systeem van registratie |
|
||||
| **Adresseerbaarheid** | Gesleuteld op subject, zodat verwijderen op verzoek kan |
|
||||
|
||||
Purge-triggers: het verstrijken van de TTL, het sluiten van de zaak, en een verwijderingsverzoek.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** de prestaties van een lokale kopie, zonder een onbevoegd register te worden. Bewaartermijn
|
||||
en het recht op verwijdering zijn echte operaties, geen hoop. Dit is consistent met zowel
|
||||
AVG-dataminimalisatie als FDS-data-bij-de-bron.
|
||||
|
||||
**Negatief en kosten:** de mapping van doel naar TTL is een beleidsbesluit, samen met de FG en de
|
||||
autorisatievoorwaarden, en geen engineeringconstante. Die is dus makkelijk fout te krijgen. Daarnaast
|
||||
komt de complexiteit van cache-invalidatie erbij.
|
||||
|
||||
**Vervolgwerk:** definieer het beleid voor doel naar TTL met de FG. Maak een toestandsdiagram voor de
|
||||
levensloop van een cache-entry. Documenteer de aanvaardbare veroudering per register.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Geen cache; altijd de bron bevragen** — afgewezen: onpraktische latency en belasting, en meer
|
||||
blootstelling per aanroep.
|
||||
- **Een onbegrensde of algemene cache** — afgewezen: die wordt een schaduwregister, precies de
|
||||
faalvorm waar de AVG en het FDS beide tegen duwen.
|
||||
@@ -1,42 +0,0 @@
|
||||
# ADR-0005: Verwerkingenlog via event-emissie, in lijn met LDV
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
AVG art. 30 vereist een register van verwerkingsactiviteiten. De FDS-bouwsteen Logboek
|
||||
Dataverwerkingen (LDV) wijst naar een gestandaardiseerd verwerkingslog dat de burger kan bevragen.
|
||||
|
||||
Database-CDC met Debezium legt *datawijzigingen* vast, en niet *verwerkingsgebeurtenissen met
|
||||
doelbinding*. Het is dus geen verwerkingenlog.
|
||||
|
||||
## Besluit
|
||||
|
||||
Elke registeradapter stuurt een **verwerkingsactiviteit-event** naar een eigen Redpanda-topic, via een
|
||||
Verwerking Port en een LDV Emitter. Het event bevat: subjectcategorie, register, velden, doel en
|
||||
doelbinding, grondslag, bevragende rol, en tijdstempel. **Nooit de opgehaalde waarden.**
|
||||
|
||||
Een projectie maakt het log bevraagbaar. De emissie is asynchroon, maar niet over te slaan: de adapter
|
||||
die de Register Port vervult, is dezelfde code die het event uitstuurt.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** het spoor voor art. 30 en LDV ontstaat als neveneffect van de bevraging, dus het kan niet
|
||||
uit de pas lopen met de werkelijkheid. Het is af te stemmen tegen de tweezijdige logs van FSC
|
||||
(ADR-0002). Het is onderscheidend in een tender.
|
||||
|
||||
**Negatief en kosten:** een topic en een projectie om te exploiteren. Het ontsluiten van het log naar
|
||||
de burger valt buiten de huidige scope; wij produceren het log. Het eventschema vraagt governance.
|
||||
|
||||
**Vervolgwerk:** definieer het schema van het verwerkingsevent. Bouw de bevraagbare projectie. Sluit
|
||||
aan op de LDV-standaard zodra die volwassen wordt; dit is een upstream-kandidaat.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Debezium-CDC hergebruiken als log** — afgewezen: dat legt datawijzigingen vast, en geen verwerking
|
||||
met doelbinding. Verkeerde semantiek.
|
||||
- **Synchroon loggen in het aanroeppad** — afgewezen: dat koppelt de latency van de bevraging aan het
|
||||
log. Asynchroon maar niet over te slaan geeft zowel snelheid als garantie.
|
||||
@@ -1,68 +0,0 @@
|
||||
# ADR-0006: Modulegrens en hergebruikstrategie voor de governed-access spine
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle (Lead Link, Build, Upstream Liaison)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
De compliance-spine uit slice 1 bestaat uit de PDP-controle (ADR-0003), gegoverneerd uitgaand verkeer
|
||||
via FSC (ADR-0002), emissie van het verwerkingenlog (ADR-0005), en de begrensde cache (ADR-0004),
|
||||
allemaal achter ports (ADR-0001). Die spine is mogelijk breder herbruikbaar dan alleen in de
|
||||
referentie-applicatie.
|
||||
|
||||
Er spelen twee hergebruikvragen: welke verpakkingsvorm kiezen wij, en hoe verhoudt de spine zich tot
|
||||
andere omgevingen zoals het OpenMetadata-datagovernanceproject?
|
||||
|
||||
Twee verduidelijkingen bepalen het besluit.
|
||||
|
||||
1. **OpenMetadata is geen afnemer.** In het datagovernanceproject is het de catalogus- en
|
||||
lineage-laag over (synthetische) data. Het bevraagt geen BRP of NHR. FSC of de begrensde cache
|
||||
daarin inbouwen zou zinloos zijn. De juiste aansluiting is **integratie van de output van de
|
||||
spine**, en niet het inbouwen van de spine.
|
||||
2. **FSC en de begrensde cache zijn zaken die alleen een afnemer aangaan.** "Maak het herbruikbaar"
|
||||
mag deze niet uitsmeren over componenten die geen registerdata bevragen.
|
||||
|
||||
Nu al een taalonafhankelijke gateway bouwen — vóórdat er een tweede, niet-.NET afnemer bestaat — zou
|
||||
de valkuil van speculatieve architectuur herhalen, die wij voor de capability-laag al hebben
|
||||
afgewezen.
|
||||
|
||||
## Besluit
|
||||
|
||||
Wij nemen een **vraaggestuurde reeks van vier stappen** aan. Elke stap hangt af van echte behoefte, en
|
||||
niet van verwachte behoefte.
|
||||
|
||||
| Stap | Wat | Wanneer |
|
||||
|---|---|---|
|
||||
| 1 | **In-process bewijzen.** Bouw de spine als gewone componenten achter ports, binnen de .NET register-applicatie. Nog geen extractie. Doel: de compliance-invarianten één keer echt valideren. | Slice 1 |
|
||||
| 2 | **Extraheren als .NET-module.** Zodra een tweede .NET-afnemer in zicht is, haal de spine eruit als een geversioneerde .NET-library of SDK. Dit is de ACL-template-extractie die het charter al plant. Herbruikbaar voor .NET-afnemers, en dat is genoeg voor register-reference en zijn broertjes. | Slice 3 |
|
||||
| 3 | **De feed LDV naar OpenMetadata aansluiten.** Route verwerkingsevents uit de LDV-emitter naar OpenMetadata als access- en usage-metadata bij het geclassificeerde asset: wie las welk persoonsgegevensveld, met welk doel, hoe vaak. Optioneel laten classificatietags uit OpenMetadata terugstromen om veldminimalisatie in de ACL aan te sturen. Dit is de concrete brug tussen beide anchor-projecten: integratie, geen inbouw. | Na stap 2 |
|
||||
| 4 | **Alleen op verzoek een taalonafhankelijke gateway bouwen.** Heeft een echte niet-.NET afnemer gegoverneerde registertoegang nodig, verpak de spine dan als zelfstandige sidecar of proxy met een dunne lokale API, met PDP, FSC-egress en LDV erachter. Niet eerder. | Op verzoek |
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** eigen software blijft minimaal. Hergebruik volgt op validatie in plaats van eraan vooraf
|
||||
te gaan. Beide anchor-projecten krijgen een concreet, benoemd integratiepunt (stap 3). Zaken die
|
||||
alleen een afnemer aangaan, blijven ingesloten.
|
||||
|
||||
**Negatief en kosten:** de .NET-module uit stap 2 dient geen niet-.NET afnemers. Dat aanvaarden wij,
|
||||
omdat stap 4 dat geval dekt zodra het echt is. Stap 3 vraagt een afgesproken schema voor het
|
||||
verwerkingsevent, stabiel genoeg voor OpenMetadata om te consumeren.
|
||||
|
||||
**Vervolgwerk:**
|
||||
|
||||
1. Neem stap 3 als expliciet integratiepunt op in beide projectpagina's in het Innovation Lab-repo:
|
||||
`projects/open-register-fd/README.md` en `projects/openmetadata/README.md`.
|
||||
2. Herzie de trigger van stap 4 bij elke portfolio-review. Bouw niet vooruit.
|
||||
3. Regel governance op het schema van het verwerkingsevent; dat is een gedeelde afhankelijkheid van
|
||||
stap 1 en stap 3.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **De taalonafhankelijke gateway vooraf bouwen** — afgewezen: speculatieve architectuur voordat er een
|
||||
tweede afnemer bestaat. De latency en de operationele kosten zijn niet te rechtvaardigen.
|
||||
- **De spine in OpenMetadata inbouwen** — afgewezen: OpenMetadata is geen afnemer. Dit is een
|
||||
categoriefout.
|
||||
- **De spine permanent in-process houden, zonder extractie** — afgewezen: dat geeft het hergebruik
|
||||
tussen projecten en applicaties weg, en dat is een kerndoel van de Open Register-inzet.
|
||||
@@ -1,27 +0,0 @@
|
||||
# ADR-NNNN: <titel>
|
||||
|
||||
- **Status:** proposed
|
||||
- **Datum:** JJJJ-MM-DD
|
||||
- **Deciders:** <rollen>
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
<De krachten die spelen: het probleem, de beperkingen, de FDS- en AVG-drijfveren. Waarom er nu een
|
||||
besluit nodig is.>
|
||||
|
||||
## Besluit
|
||||
|
||||
<De keuze, eenvoudig gesteld.>
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** <wat dit oplevert>
|
||||
|
||||
**Negatief en kosten:** <wat het kost, en wat wij aanvaarden>
|
||||
|
||||
**Vervolgwerk:** <welk werk dit oproept>
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
<De afgewezen opties, en waarom.>
|
||||
@@ -1,127 +0,0 @@
|
||||
# C4-componentview — register-applicatie en capability-laag
|
||||
|
||||
> Niveau 3, de componentview. Deze view zoomt in op de container van de .NET register-applicatie uit
|
||||
> het L2-containerdiagram. Zij verbindt het geheel op componentniveau — domein, ports, adapters en de
|
||||
> FDS-capability-componenten — en toont waar elk onderdeel externe tooling raakt.
|
||||
>
|
||||
> De hexagonale structuur is expliciet: het domein hangt alleen af van **ports** (interfaces). Elke
|
||||
> concrete capability is een **adapter** die aan een port is gebonden.
|
||||
>
|
||||
> De containerview (L2), de blauwdruk en de privacy-datastroomviews staan in het Innovation Lab-repo,
|
||||
> `Respellion/innovation-lab`, onder `projects/open-register-fd/`.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Componentview — register-applicatie (.NET) en de FDS-capability-laag
|
||||
|
||||
Person(user, "Behandelaar", "Behandelt zaken")
|
||||
Container(spa, "Frontend", "Angular + NL Design System", "Zaakinterface")
|
||||
|
||||
Container_Boundary(app, "Register-applicatie (.NET, hexagonaal)") {
|
||||
Component(api, "API / application services", ".NET", "Orkestreert use cases; verklaart doelbinding per vraag")
|
||||
Component(domain, "Domeinmodel", ".NET / DDD", "Ubiquitous language; geen registervocabulaire")
|
||||
|
||||
Component(portReg, "Register Port", "interface", "De vraag van het domein: Personen / Organisaties")
|
||||
Component(portPol, "Authorisation Port", "interface", "mag-deze-verwerking-doorgaan?")
|
||||
Component(portLog, "Verwerking Port", "interface", "leg de verwerkingsgebeurtenis vast")
|
||||
Component(portTm, "Terugmelding Port", "interface", "meld een vermoedelijke fout")
|
||||
Component(portCache, "Cache Port", "interface", "doelgebonden lezen, schrijven en verwijderen")
|
||||
|
||||
Component(aclBrp, "BRP-adapter", ".NET", "Vertaalt domein<->BRP; minimale velden")
|
||||
Component(aclKvk, "NHR/KVK-adapter", ".NET", "Vertaalt domein<->NHR; UBO-bewust")
|
||||
Component(pdpClient, "PDP Client", ".NET -> OPA", "Roept de policy engine; geeft doel en grondslag mee")
|
||||
Component(ldvEmit, "LDV Emitter", ".NET", "Bouwt het verwerkingsevent; publiceert naar Redpanda")
|
||||
Component(fscClient, "FSC Client", ".NET", "Stuurt contractuele aanroepen via de outway")
|
||||
Component(cacheMgr, "Cache Manager", ".NET", "TTL en verwijderen op subjectsleutel")
|
||||
Component(tmHandler, "Terugmelding Handler", ".NET -> Flowable", "Start het terugmeldproces")
|
||||
Component(procClient, "Process Client", ".NET -> Flowable", "Uitvoering van BPMN en DMN")
|
||||
}
|
||||
|
||||
System_Ext(opa, "OPA (PDP)", "Policies geversioneerd in Gitea")
|
||||
System_Ext(fsc, "FSC Outway", "EUPL-referentie-implementatie")
|
||||
System_Ext(flowable, "Flowable", "BPMN + DMN")
|
||||
ContainerDb_Ext(cache, "Begrensde cache", "PostgreSQL")
|
||||
System_Ext(redpanda, "Redpanda", "LDV-topic + CDC")
|
||||
System_Ext(brp, "BRP", "via FSC inway")
|
||||
System_Ext(kvk, "NHR / KVK", "via FSC inway")
|
||||
System_Ext(kanidm, "Kanidm", "OIDC")
|
||||
|
||||
Rel(user, spa, "Gebruikt")
|
||||
Rel(spa, api, "REST/JSON")
|
||||
Rel(kanidm, api, "OIDC", "authenticatie")
|
||||
Rel(api, domain, "Roept aan")
|
||||
Rel(api, portPol, "Controleert vóór de bevraging")
|
||||
Rel(api, portReg, "Vraagt data")
|
||||
Rel(api, portTm, "Dient melding in")
|
||||
Rel(api, procClient, "Voert proces uit")
|
||||
|
||||
Rel(portPol, pdpClient, "gebonden aan")
|
||||
Rel(pdpClient, opa, "besluitverzoek")
|
||||
|
||||
Rel(portReg, aclBrp, "gebonden aan")
|
||||
Rel(portReg, aclKvk, "gebonden aan")
|
||||
Rel(aclBrp, fscClient, "via")
|
||||
Rel(aclKvk, fscClient, "via")
|
||||
Rel(aclBrp, portLog, "stuurt event")
|
||||
Rel(aclKvk, portLog, "stuurt event")
|
||||
Rel(aclBrp, portCache, "leest en schrijft")
|
||||
Rel(aclKvk, portCache, "leest en schrijft")
|
||||
Rel(fscClient, fsc, "contractuele aanroep")
|
||||
Rel(fsc, brp, "mTLS + contract")
|
||||
Rel(fsc, kvk, "mTLS + contract")
|
||||
|
||||
Rel(portLog, ldvEmit, "gebonden aan")
|
||||
Rel(ldvEmit, redpanda, "publiceert")
|
||||
Rel(portCache, cacheMgr, "gebonden aan")
|
||||
Rel(cacheMgr, cache, "slaat op")
|
||||
Rel(portTm, tmHandler, "gebonden aan")
|
||||
Rel(tmHandler, flowable, "start proces")
|
||||
Rel(procClient, flowable, "voert uit")
|
||||
```
|
||||
|
||||
## Hoe je dit leest
|
||||
|
||||
1. **De ports zijn de naad.** Het domein en de application services hangen af van de vijf interfaces,
|
||||
en nooit van adapters. FSC wisselen voor DSP, of OPA voor de latere FTV-client, verandert een
|
||||
adapter — geen port, en niet het domein. Dit is de clock-speed boundary, concreet gemaakt.
|
||||
2. **De compliance-componenten zijn adapters, geen domeinlogica.** De PDP-client, de LDV-emitter, de
|
||||
FSC-client en de cache manager staan allemaal aan de adapterzijde. Een bevraging kan er fysiek niet
|
||||
langs, omdat de adapter die de Register Port vervult dezelfde code is die het LDV-event uitstuurt
|
||||
en via FSC routeert.
|
||||
3. **Slechts twee componenten raken de registers**: de BRP-adapter en de NHR/KVK-adapter. Beide
|
||||
bereiken ze uitsluitend via de FSC-client. Er is geen vierde pad.
|
||||
|
||||
## Componenten tegenover verplichtingen
|
||||
|
||||
| Component | Omvang eigen bouw | Verplichting die het afdekt |
|
||||
|---|---|---|
|
||||
| Domeinmodel | het product | correctheid van de bedrijfsregels |
|
||||
| BRP- en NHR-adapters | dun | dataminimalisatie: vertalen en velden versmallen |
|
||||
| PDP Client | klein | handhaven van grondslag en doelbinding |
|
||||
| LDV Emitter | klein | verwerkingenlog (AVG art. 30 en LDV) |
|
||||
| FSC Client | klein | geautoriseerde, gelogde connectiviteit |
|
||||
| Cache Manager | klein | grenzen aan bewaring, en verwijdering |
|
||||
| Terugmelding Handler | klein | de terugmeldplicht van de afnemer |
|
||||
|
||||
---
|
||||
|
||||
## Aanvullende views die voor engineers waarde hebben
|
||||
|
||||
De diagrammen tot hier verklaren *structuur* en *compliance-intentie*. Engineers die dit bouwen,
|
||||
hebben er nog een aantal nodig. Wij tekenen geen view voordat er iets echt is om te beschrijven, dus
|
||||
elke regel noemt de trigger.
|
||||
|
||||
| # | View | Wat het toevoegt | Trigger |
|
||||
|---|---|---|---|
|
||||
| 1 | **Deploymentview** (C4 deployment, topologie) | Waar elke container op De Werf draait: k3s-namespaces, welke services sidecar zijn en welke een eigen pod (is OPA een sidecar of centraal? waar eindigt de FSC outway?), netwerkpolicies tussen de vlakken van de vertrouwensgrens, en beheer van secrets en mTLS-certificaten voor FSC. Hier worden de privacy*grenzen* echte firewall- en netwerkregels. | Vóór de eerste deploy met meerdere services. **Hoogste waarde als volgende.** |
|
||||
| 2 | **Sequences voor de niet-gelukkige paden** | Wij hebben het gelukkige pad. Engineers hebben de lastige nodig: PDP-*deny* midden in een transactie, een verlopen of ingetrokken FSC-contract, een register-timeout terwijl er een verouderde cache-entry ligt, en een gedeeltelijk NHR-antwoord waarbij een UBO-veld is achtergehouden. Dit bepaalt de foutafhandeling, en hier verstoppen de compliance-randgevallen zich. | Direct na slice 1. |
|
||||
| 3 | **Domeinmodel en ERD** | De bounded contexts en aggregates in het domein, plus het cacheschema: welke persoonsgegevens blijven staan, op welke sleutel, en met welke purge-kolom. Dit is tegelijk het artefact dat de FG beoordeelt voor bewaartermijnen. | Zodra het domein in slice 1 stabiliseert. |
|
||||
| 4 | **Dataclassificatie- en catalogusview** | Elk veld dat een grens kruist, getagd — persoonsgegeven? bijzondere categorie? UBO-beperkt? — en gemapt op zijn classificatie in OpenMetadata. Dit stuurt de GDPR-scrubbingregels en de lineage-tags. | Beter *uit* OpenMetadata gegenereerd zodra die gevuld is, dan met de hand getekend. |
|
||||
| 5 | **Toestandsdiagram: levensloop van een cache-entry** | `fetched` → `valid` (binnen TTL) → `stale` → `purged` (TTL verstreken \| zaak gesloten \| verwijderingsverzoek). Klein, maar het pint de bewaarsemantiek vast die "begrensde cache" nu alleen in prose beschrijft. | Samen met ADR-0004-vervolgwerk. |
|
||||
| 6 | **BPMN-view: de terugmelding-workflow** | Het Flowable-proces zelf: ingediend → verstuurd naar bronhouder → bevestigd → opgelost of afgewezen. Dit is uitvoerbaar BPMN, dus het diagram en de implementatie zijn hetzelfde artefact. | Wanneer de terugmelding-slice start. |
|
||||
| 7 | **Threat model en vertrouwensgrensview** (STRIDE-stijl) | Dreigingen over de vertrouwensgrens leggen: tokendiefstal, cache poisoning, replay tegen FSC, policy bypass, en manipulatie van logs. Past natuurlijk bij de FSC-zoom, en is het anker van het securitygesprek. | Vóór het verwerken van echte persoonsgegevens. |
|
||||
| 8 | **CI/CD- en policy-promotieview** | Hoe OPA-policies en BPMN/DMN-modellen van een pull request naar draaiende configuratie gaan. "Toegangsbeheer is configuratie in Gitea" geldt alleen als er een pijplijn is die review en promotie handhaaft. | Samen met het vervolgwerk uit ADR-0003. |
|
||||
|
||||
**Voorstel voor de volgende twee.** De **deploymentview**, omdat die de privacygrenzen omzet in
|
||||
handhaafbare netwerkpolicy. En de **sequences voor de niet-gelukkige paden**, omdat compliance daar
|
||||
werkelijk breekt.
|
||||
@@ -1,103 +0,0 @@
|
||||
# POC-voorstel — slice 1: walking skeleton (één register, gegoverneerde bevraging)
|
||||
|
||||
> Klaar om in een `poc-voorstel`-issue te plakken, met de labels `build` en `poc`. Dit is het bouwbare
|
||||
> eerste increment dat de architectuurdocumenten beschrijven. Het bewijst met opzet de
|
||||
> *compliance-spine* end-to-end op de dunst mogelijke functionaliteit.
|
||||
|
||||
## Probleem en strategische vraag
|
||||
|
||||
Kunnen wij een registerbevraging demonstreren die *structureel* gegoverneerd is — onmogelijk uit te
|
||||
voeren zonder gehandhaafde grondslag en een automatische regel in het verwerkingenlog — op onze
|
||||
soevereine stack?
|
||||
|
||||
Dit is de geloofwaardigheidstoets achter de hele Open Register-inzet (slice 1 van het charter) en
|
||||
achter de FDS gap-analyse.
|
||||
|
||||
## Hypothese
|
||||
|
||||
Wij verwachten dat het doorverbinden van één registerbevraging door de volledige capability-spine —
|
||||
Register Port → ACL-adapter → PDP-controle → FSC-aanroep → LDV-emissie → begrensde cache — de claim
|
||||
"compliance is structureel" bewijst.
|
||||
|
||||
Wij weten dat wij het goed hebben als een geautomatiseerde test aantoont dat een bevraging **niet** kan
|
||||
voltooien als de PDP weigert, en **altijd** een LDV-event oplevert als de PDP toestaat.
|
||||
|
||||
## Scope ter grootte van één blok
|
||||
|
||||
**Wel in scope**
|
||||
|
||||
| Onderdeel | Wat |
|
||||
|---|---|
|
||||
| Register | **NHR/KVK**, basisgegevens over onderneming en bestuurder. Gekozen boven BRP; zie de slotnotitie. |
|
||||
| Use case | Geef bij een KVK-nummer de geregistreerde organisatie terug aan het domein, voor één verklaard doel. |
|
||||
| Ports | De vijf ports als interface. Concrete adapters: NHR-ACL, PDP-client (OPA), FSC-client met sandbox- of test-outway, LDV-emitter (Redpanda-topic), en cache manager (PostgreSQL met TTL). |
|
||||
| Policy | OPA draait met één handgeschreven voorbeeldpolicy in Gitea: één allow-regel en één deny-geval. |
|
||||
| Log | Verwerkingsevent-schema v0 plus een minimale bevraagbare projectie; een tabelweergave is genoeg. |
|
||||
| Tests | Tests die de twee compliance-invarianten vastleggen: deny blokkeert, allow logt. |
|
||||
|
||||
**Niet in scope** — even belangrijk om op te schrijven.
|
||||
|
||||
1. Afgewerkte interface of NL Design System-schermen, verder dan een dev-harness.
|
||||
2. BRP en paden met veel persoonsgegevens. Die gaan naar slice 2, met een door de FG beoordeelde
|
||||
policy.
|
||||
3. UBO-data. Het regime van beperkte toegankelijkheid valt buiten deze slice.
|
||||
4. De terugmelding-workflow (latere slice), DCAT-export, en Superset-dashboards.
|
||||
5. Echte register-endpoints. Alleen sandbox en stubs.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Een bevraging op KVK-nummer geeft een domein-`Organisatie` terug via de NHR-ACL-adapter, zonder
|
||||
registervocabulaire in het domein (ADR-0001).
|
||||
- [ ] De aanroep loopt via de FSC-client naar een sandbox-outway, en niet via een ruwe HTTP-client
|
||||
(ADR-0002).
|
||||
- [ ] Er vindt geen bevraging plaats tenzij de PDP allow teruggeeft voor de combinatie rol, doel en
|
||||
grondslag (ADR-0003).
|
||||
- [ ] Elke toegestane bevraging stuurt precies één verwerkingsevent naar Redpanda, bevraagbaar in de
|
||||
projectie, zonder opgehaalde waarden (ADR-0005).
|
||||
- [ ] Cache-entries dragen een TTL en een subjectsleutel; een purge-aanroep verwijdert ze (ADR-0004).
|
||||
- [ ] **De tests op de compliance-invarianten slagen in CI:** (a) PDP-deny betekent geen FSC-aanroep;
|
||||
(b) PDP-allow betekent precies één LDV-event; (c) te ruim gevraagde velden bereiken het domein
|
||||
nooit.
|
||||
- [ ] Het geheel draait lokaal uit een gedocumenteerd `compose`- of k3s-manifest met stubs, zonder
|
||||
echte registertoegang.
|
||||
- [ ] ADR-0001 tot en met ADR-0005 zijn vanuit de code gelinkt. Eén nieuwe ADR als er in slice 1 een
|
||||
besluit ontstaat.
|
||||
|
||||
## Acceptatiedemo (bewijs voor de week-3-toets)
|
||||
|
||||
Live: een geslaagde bevraging plus de bijbehorende LDV-regel. Zet daarna de policy op deny en toon
|
||||
dezelfde bevraging geweigerd, zonder registeraanroep en zonder data.
|
||||
|
||||
Dat contrast *is* de demo.
|
||||
|
||||
## Ontvangende Delivery Circle (voorlopig)
|
||||
|
||||
De register-reference Delivery Circle. De Handoff-ontvanger krijgt bij de kickoff een naam.
|
||||
|
||||
Waarschijnlijke adoptie: de capability-spine wordt het herbruikbare substraat voor de
|
||||
register-reference-applicatie.
|
||||
|
||||
## Upstream-kandidaten
|
||||
|
||||
| Project | Wat wij kunnen bijdragen |
|
||||
|---|---|
|
||||
| fsc-nlx | Ergonomie van de sandbox en testomgeving, plus documentatie |
|
||||
| OPA | Policy-patronen voor het modelleren van Nederlandse grondslagen |
|
||||
| OpenMetadata | Later een DCAT-AP-NL exporter; dit verbindt het OpenMetadata-project |
|
||||
|
||||
## AVG- en soevereiniteitsoverwegingen
|
||||
|
||||
Alleen NHR-basisgegevens, over onderneming en bestuurder, en in slice 1 **gestubd**. Er worden geen
|
||||
echte persoonsgegevens verwerkt.
|
||||
|
||||
Een FG-review is een voorwaarde voor slice 2, met echte data en BRP. Alle componenten draaien
|
||||
zelfgehost op De Werf; OPA-policies en BPMN staan in Gitea.
|
||||
|
||||
## Slotnotitie: waarom NHR vóór BRP voor het skeleton
|
||||
|
||||
Beide registers bevatten persoonsgegevens, dus geen van beide is "gratis". NHR-basisgegevens over
|
||||
onderneming en bestuurder zijn echter minder gevoelig dan BRP-gegevens over inwoners, en er is een
|
||||
duidelijker verhaal rond een publieke sandbox.
|
||||
|
||||
Zo bewijst slice 1 het *mechanisme*, voordat slice 2 BRP oppakt onder een door de FG beoordeelde
|
||||
policy. UBO-data blijft buiten scope tot het toegangsregime is gemodelleerd.
|
||||
@@ -0,0 +1,464 @@
|
||||
# Demo script
|
||||
|
||||
A running log of demoable outcomes, one section per slice. Each entry is a short,
|
||||
copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||
|
||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||
runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton
|
||||
(portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection).
|
||||
|
||||
```bash
|
||||
# 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180).
|
||||
make up
|
||||
|
||||
# 2. Automated happy path — Playwright, inside the compose network (issuer-consistent):
|
||||
make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation
|
||||
|
||||
# 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen".
|
||||
open http://localhost:8140
|
||||
```
|
||||
|
||||
> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
|
||||
> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
|
||||
|
||||
---
|
||||
|
||||
## S-08c — Self-service submit form (NL Design System + DigiD)
|
||||
|
||||
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
|
||||
self-service portal (NL Design System styling); the page confirms with the reference returned by the
|
||||
BFF. The bsn comes from the DigiD token, so it's a confirm-and-submit flow (no bsn field).
|
||||
|
||||
```bash
|
||||
# 1. Bring the backend + Keycloak up (BFF on :8080, Keycloak on :8180).
|
||||
make up
|
||||
|
||||
# 2. Serve the portal (dev server); it redirects to Keycloak for DigiD login.
|
||||
pnpm nx serve self-service # → http://localhost:4200
|
||||
|
||||
# 3. In the browser: log in as the mock DigiD user jan-burger / test123, then submit.
|
||||
# The page shows the returned registration reference.
|
||||
```
|
||||
|
||||
> First real UI. The full **login → submit → success** happy path is automated in **S-08d**
|
||||
> (Playwright, against the compose-served app). Component tests + an axe WCAG 2.1 AA check on the
|
||||
> submit page run headless in the `frontend` CI lane. See `docs/frontend-decisions.md`.
|
||||
|
||||
---
|
||||
|
||||
## S-08a — Nx workspace + self-service portal skeleton
|
||||
|
||||
**Outcome:** the frontend foundation — an Nx (pnpm) monorepo with the `self-service` Angular app
|
||||
(standalone + signals), lint/test/build green in a CI Node lane. The login + submit form follow in
|
||||
S-08c.
|
||||
|
||||
```bash
|
||||
# From a fresh clone (Node 24 + pnpm 11):
|
||||
pnpm install # native builds are pre-approved in pnpm-workspace.yaml
|
||||
pnpm nx test self-service # Vitest component test
|
||||
pnpm nx build self-service # production build
|
||||
pnpm nx serve self-service # → http://localhost:4200 (placeholder page)
|
||||
# Or the CI-equivalent one-shot:
|
||||
make frontend # install + nx lint/test/build
|
||||
```
|
||||
|
||||
> Nx manages only `apps/`+`libs/`; the .NET services stay on `dotnet`/the Makefile. NL Design System
|
||||
> and the real form arrive in S-08c (#67); see `docs/frontend-decisions.md`.
|
||||
|
||||
---
|
||||
|
||||
## S-07 — BFF: the portals' single backend
|
||||
|
||||
**Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the
|
||||
bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the
|
||||
front door the portals (S-08/S-09) will talk to.
|
||||
|
||||
**The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and
|
||||
BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010.
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up.
|
||||
make up
|
||||
|
||||
# 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar).
|
||||
make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..."
|
||||
|
||||
# 3. Try it by hand (BFF on host port 8080).
|
||||
# a) A digid access token for the mock user jan-burger (bsn 123456782):
|
||||
tok=$(curl -s -X POST http://localhost:8180/realms/digid/protocol/openid-connect/token \
|
||||
-d grant_type=password -d client_id=big-portal -d username=jan-burger -d password=test123 \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
||||
|
||||
# b) Submit — without the token it is 401; with it, 202:
|
||||
curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations
|
||||
curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \
|
||||
-H "Authorization: Bearer $tok"
|
||||
|
||||
# c) The openbaar register is anonymous and exposes only id + status (never the bsn):
|
||||
curl -fsS http://localhost:8080/openbaar/register | jq
|
||||
```
|
||||
|
||||
> The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no
|
||||
> token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built
|
||||
> from it.
|
||||
|
||||
---
|
||||
|
||||
## S-05 — BIG Domain Service: submit a registration
|
||||
|
||||
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
|
||||
opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton
|
||||
that produces the zaak S-06 then projects.
|
||||
|
||||
**The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken`
|
||||
worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009).
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
||||
make up
|
||||
|
||||
# 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the
|
||||
# ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up).
|
||||
make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration"
|
||||
|
||||
# 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back.
|
||||
loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p')
|
||||
|
||||
# 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll
|
||||
# until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.)
|
||||
curl -fsS "http://localhost:8130$loc" | jq
|
||||
# → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/<uuid>" }
|
||||
```
|
||||
|
||||
> Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the
|
||||
> projection (S-06), fed by the very zaak this flow opens.
|
||||
|
||||
---
|
||||
|
||||
## S-06 — Event Subscriber + read projection
|
||||
|
||||
**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which
|
||||
projects it into a rebuildable read projection the projection-api serves.
|
||||
|
||||
**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber →
|
||||
`register_projection` → projection-api `GET /register`.
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
||||
make up
|
||||
|
||||
# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back.
|
||||
# (The verify-projection check does exactly this end-to-end and asserts the result.)
|
||||
make verify-projection # → "OK — projection-api serves zaak <uuid> with status INGEDIEND"
|
||||
|
||||
# 3. Observe the projection directly via the read API (host port 8120).
|
||||
curl -fsS http://localhost:8120/register | jq
|
||||
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ]
|
||||
|
||||
# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the
|
||||
# notification log (no OpenZaak access needed — ADR-0008).
|
||||
curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110
|
||||
curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
|
||||
```
|
||||
|
||||
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
|
||||
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
|
||||
|
||||
---
|
||||
|
||||
## 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+).
|
||||
|
||||
## #78 — One reference across both portals (ADR-0012)
|
||||
|
||||
Before this change the self-service confirmation and the openbaar register showed **different**
|
||||
identifiers, so a citizen could not look their registration back up. Now both show the same
|
||||
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
|
||||
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
|
||||
it in the replay log so rebuild stays log-only (ADR-0008).
|
||||
|
||||
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
|
||||
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
|
||||
|
||||
```bash
|
||||
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
|
||||
ref="<registration-reference-from-the-confirmation>"
|
||||
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
|
||||
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
|
||||
```
|
||||
|
||||
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
|
||||
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
|
||||
|
||||
## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013)
|
||||
|
||||
A behandelaar now works submitted registrations in a real portal instead of the temporary admin
|
||||
endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable
|
||||
`Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the
|
||||
Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011)
|
||||
or **afwijzen** — which also completes the Beoordelen task so the process advances.
|
||||
|
||||
```text
|
||||
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
||||
# http://localhost:8142/ → merel-behandelaar / test123
|
||||
#
|
||||
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
||||
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
||||
#
|
||||
# 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register
|
||||
# (http://localhost:8141/) now shows that reference as INGESCHREVEN.
|
||||
```
|
||||
|
||||
**The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy,
|
||||
`medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task →
|
||||
ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN.
|
||||
|
||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||
> drives the behandel portal in place of the old admin endpoint.
|
||||
|
||||
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||
|
||||
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||
behandelaar's werkbak.
|
||||
|
||||
```text
|
||||
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||
# note the "Referentie" on the confirmation.
|
||||
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||
# appears — its Beoordelen task was cancelled.
|
||||
```
|
||||
|
||||
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||
interrupting boundary event ends it → the werkbak drops the case.
|
||||
|
||||
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||
|
||||
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
|
||||
|
||||
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
|
||||
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
|
||||
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
|
||||
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
|
||||
its identity throughout — only who may claim it changes.
|
||||
|
||||
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
|
||||
verify-domain check automates):
|
||||
|
||||
```bash
|
||||
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
|
||||
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
|
||||
# but NOT claimed.
|
||||
#
|
||||
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
|
||||
FL=http://localhost:8090/flowable-rest/service
|
||||
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"execute"}'
|
||||
#
|
||||
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
|
||||
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
|
||||
```
|
||||
|
||||
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
|
||||
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
|
||||
candidate group behandelaar → teamlead (§8.2).
|
||||
|
||||
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
|
||||
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
|
||||
> and reassignment are asserted live by the verify-domain check.
|
||||
|
||||
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
|
||||
|
||||
A registration's diploma origin decides its route. A DMN service task in the registratie
|
||||
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
|
||||
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
|
||||
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
|
||||
DMN, not in code — a beheerder can read and adjust the decision table directly.
|
||||
|
||||
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
|
||||
the domain directly, so the demo drives it through the domain endpoint:
|
||||
|
||||
```bash
|
||||
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
|
||||
DOM=http://localhost:8080 # domain service
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
||||
#
|
||||
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
|
||||
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
||||
FL=http://localhost:8090/flowable-rest/service
|
||||
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
|
||||
#
|
||||
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
|
||||
```
|
||||
|
||||
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
|
||||
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
|
||||
domestic: `Beoordelen` directly (§8.2, ADR-0016).
|
||||
|
||||
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
|
||||
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
|
||||
> foreign→CBGV routing are asserted live by the verify-domain check.
|
||||
|
||||
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
|
||||
|
||||
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
|
||||
for the citizen's documents (their diploma). Two things can happen:
|
||||
|
||||
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
|
||||
routing (S-13) → beoordeling.
|
||||
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
|
||||
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
|
||||
**VERLOPEN** (the case is cancelled).
|
||||
|
||||
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
|
||||
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
|
||||
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
|
||||
demonstrated by firing the 30-day timer early via the management API.
|
||||
|
||||
```bash
|
||||
DOM=http://localhost:8080 # domain service
|
||||
FL=http://localhost:8090/flowable-rest/service # flowable-rest
|
||||
|
||||
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
|
||||
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
|
||||
|
||||
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||
|
||||
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
|
||||
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
|
||||
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
|
||||
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
|
||||
```
|
||||
|
||||
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
|
||||
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
|
||||
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
|
||||
|
||||
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
|
||||
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
|
||||
> verify-domain check.
|
||||
|
||||
## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018)
|
||||
|
||||
The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting,
|
||||
the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to
|
||||
the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW
|
||||
`enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes
|
||||
the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row —
|
||||
# choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd".
|
||||
open http://localhost:8140
|
||||
#
|
||||
# 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves.
|
||||
make verify-e2e
|
||||
#
|
||||
# 3. The ACL integration test proves the document is really created in the Documenten API and
|
||||
# related to the zaak (against a live OpenZaak):
|
||||
make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak"
|
||||
```
|
||||
|
||||
**The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain
|
||||
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
|
||||
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
|
||||
|
||||
> Setting the ZGW zaak to a cancellation status on 30-day expiry is a follow-up (S-10c, #106).
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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.
|
||||
|
||||
## Behandel portal (S-12, #13)
|
||||
|
||||
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
|
||||
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
|
||||
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
|
||||
**ADR-0013**; this entry records the frontend-specific choices.
|
||||
|
||||
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
|
||||
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
|
||||
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
|
||||
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
|
||||
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
|
||||
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
|
||||
(secure route), exactly as self-service does for `/self-service/`.
|
||||
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
|
||||
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
|
||||
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
|
||||
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
|
||||
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
|
||||
403 for a non-behandelaar) rather than swallowing it.
|
||||
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
|
||||
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
|
||||
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
|
||||
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
|
||||
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
|
||||
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
|
||||
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
|
||||
e2e (and screen readers) can target a specific registration in a shared werkbak.
|
||||
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
|
||||
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
|
||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||
path.
|
||||
|
||||
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||
|
||||
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||
|
||||
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user