Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa1a370ac | ||
|
|
e8cb1ec7e9 | ||
|
|
de6db7d35b | ||
|
|
d17e79959b | ||
|
|
fc036d53d5 | ||
|
|
9d7e8e5b65 | ||
|
|
17f1f2f809 | ||
|
|
1dd8bd4e1b | ||
|
|
d6b3f9764f | ||
|
|
8b206a005f | ||
|
|
d0fb2b3e8c | ||
|
|
321ee50dcb | ||
|
|
94720f0fcb | ||
|
|
94742a261f | ||
|
|
2125fb0cfd | ||
|
|
0cd70ae8c3 | ||
|
|
d37d4c96c6 | ||
|
|
159f014c1e | ||
|
|
dd54688f86 | ||
|
|
0a97fa4bf7 | ||
|
|
23ea91de32 | ||
|
|
0494730223 | ||
|
|
fff88ca23d | ||
|
|
849bf4723b | ||
|
|
4698c869f3 | ||
|
|
d5dfbdc0b2 | ||
|
|
6771fccf47 | ||
|
|
88338396f6 | ||
|
|
4274fd30d1 | ||
|
|
4fe9915816 | ||
|
|
5f8ab4dbcd | ||
|
|
5de8c1e292 | ||
|
|
183d0bce31 | ||
|
|
d5e5fa254c | ||
|
|
bf234e1322 | ||
|
|
c8fdfbb699 | ||
|
|
0904df8db0 | ||
|
|
4777ff2b1d | ||
|
|
ccae27b3da | ||
|
|
7bcbc726ce | ||
|
|
8a537edd6c | ||
|
|
e7bed37cda | ||
|
|
94699f3603 | ||
|
|
951bdd8364 | ||
|
|
2397d9196a | ||
|
|
a34caba9ea | ||
|
|
1f1c944a8b | ||
|
|
3abf8f7ccf | ||
|
|
d226b6402d | ||
|
|
9c3da48d8e | ||
|
|
4085bdead7 | ||
|
|
d4ed0ffc22 | ||
|
|
3023bb6fbe | ||
|
|
9997da8beb |
+159
-5
@@ -9,6 +9,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
|
||||
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||
|
||||
@@ -35,6 +41,27 @@ jobs:
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make lint
|
||||
|
||||
# The Helm chart's only automated gate: it renders and schema-checks the whole
|
||||
# stack, and checks it still describes the same stack as the compose file
|
||||
# (ADR-0033). No cluster involved — see docs/runbooks/kubernetes-talos.md.
|
||||
k8s:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# helm as its pinned static binary rather than a marketplace action: one URL,
|
||||
# the same one the Talos runbook §0 gives a developer, and no third-party
|
||||
# action to vet (CLAUDE.md §13). The drift check also needs `docker compose`,
|
||||
# which the runner already has (see docs/runbooks/ci.md).
|
||||
- name: Install helm
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -sSL https://get.helm.sh/helm-v3.16.4-linux-amd64.tar.gz \
|
||||
| tar xz -O linux-amd64/helm > "$HOME/.local/bin/helm"
|
||||
chmod +x "$HOME/.local/bin/helm"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: make k8s-lint
|
||||
- run: make k8s-drift
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -64,6 +91,12 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make unit
|
||||
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
||||
- name: Unit test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||
frontend:
|
||||
@@ -78,6 +111,12 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
- run: make frontend
|
||||
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
||||
- name: Frontend test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mutation:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -93,6 +132,29 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- run: make mutation
|
||||
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
||||
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
||||
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
||||
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
||||
- name: Mutation score summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
{
|
||||
echo "## 🧬 Mutation testing"
|
||||
echo
|
||||
for svc in acl event-subscriber domain bff; do
|
||||
echo "### $svc"
|
||||
echo
|
||||
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
||||
if [ -n "$report" ]; then
|
||||
sed '1s/^\xef\xbb\xbf//' "$report"
|
||||
else
|
||||
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# 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
|
||||
@@ -129,35 +191,127 @@ jobs:
|
||||
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
|
||||
# One stage for every check that needs the live stack. 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).
|
||||
#
|
||||
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
|
||||
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
|
||||
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
|
||||
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
|
||||
#
|
||||
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
|
||||
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
|
||||
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
|
||||
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
||||
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
||||
# re-run exercises verify-stack, so we still get the signal.
|
||||
verify-stack:
|
||||
needs: [mutation]
|
||||
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).
|
||||
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
||||
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
||||
- name: Bring up the full stack & wait for health
|
||||
id: up
|
||||
run: make verify-up
|
||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||
id: obs
|
||||
run: OBS_TIMEOUT=180 make verify-observability
|
||||
- name: Objecttypen API up + token authenticates
|
||||
id: objecttypen
|
||||
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
|
||||
- name: Objecten API up + token authenticates + trusts Objecttypen
|
||||
id: objecten
|
||||
run: OBJECTEN_TIMEOUT=120 make verify-objecten
|
||||
- name: RegisterRecord objecttype registered + published
|
||||
id: registerrecord
|
||||
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
id: acl
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
id: nrc
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
id: projection
|
||||
run: make verify-projection
|
||||
- name: Objecten → NRC notification delivery
|
||||
id: objecten_nrc
|
||||
run: make verify-objecten-notifications
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
id: domain
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
id: bff
|
||||
run: make verify-bff
|
||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||
id: tracing
|
||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||
id: metrics
|
||||
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||
- name: Self-service e2e (Playwright, login → submit → success)
|
||||
id: e2e
|
||||
run: make verify-e2e
|
||||
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
||||
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
||||
# job-level status-function `if` of #134) so it renders even after a check fails.
|
||||
- name: verify-stack check summary
|
||||
if: always()
|
||||
env:
|
||||
UP: ${{ steps.up.outcome }}
|
||||
OBS: ${{ steps.obs.outcome }}
|
||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||
ACL: ${{ steps.acl.outcome }}
|
||||
NRC: ${{ steps.nrc.outcome }}
|
||||
PROJECTION: ${{ steps.projection.outcome }}
|
||||
DOMAIN: ${{ steps.domain.outcome }}
|
||||
BFF: ${{ steps.bff.outcome }}
|
||||
TRACING: ${{ steps.tracing.outcome }}
|
||||
METRICS: ${{ steps.metrics.outcome }}
|
||||
E2E: ${{ steps.e2e.outcome }}
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
|
||||
{
|
||||
echo "## 🔌 verify-stack checks"
|
||||
echo
|
||||
echo "| Check | Result |"
|
||||
echo "| ----- | :----: |"
|
||||
echo "| Bring up + health | $(icon "$UP") |"
|
||||
echo "| Observability backplane | $(icon "$OBS") |"
|
||||
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
||||
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
||||
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
||||
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
||||
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
||||
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
||||
- name: e2e spec summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
# 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 2>&1 || true
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Deploy to Talos
|
||||
|
||||
# A merge to main ships the stack to the Talos cluster on the lab server
|
||||
# (docs/runbooks/kubernetes-talos.md §9). PR CI is the merge gate, so main is
|
||||
# green by construction — this workflow only deploys.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Queue deploys, never cancel one: a helm upgrade killed half-way leaves the
|
||||
# release in `pending-upgrade` and the next run has to be unwedged by hand.
|
||||
concurrency:
|
||||
group: deploy-talos
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The Talos VM as seen from the Fedora host (libvirt guest IP), and the
|
||||
# address a browser uses to reach the cluster. `localhost` is deliberate:
|
||||
# the portals' PKCE needs a secure context, so they are reached over
|
||||
# `kubectl port-forward` — runbook §5. Override with repo variables.
|
||||
TALOS_VM_IP: ${{ vars.TALOS_VM_IP }}
|
||||
TALOS_HOST: ${{ vars.TALOS_HOST }}
|
||||
# Set it and the stack is published over TLS on <sub>.<domain> by the
|
||||
# in-cluster edge (ADR-0035, runbook §10). Empty = NodePorts, as before.
|
||||
PUBLIC_DOMAIN: ${{ vars.PUBLIC_DOMAIN }}
|
||||
PUBLIC_EMAIL: ${{ vars.PUBLIC_EMAIL }}
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
# Pinned static binaries, the same URLs the Talos runbook §0 gives a
|
||||
# developer and the same helm the `k8s` CI job uses — no action to vet.
|
||||
- name: Install kubectl, helm and crane
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin="$HOME/.local/bin"; mkdir -p "$bin"
|
||||
curl -sSLo "$bin/kubectl" https://dl.k8s.io/release/v1.37.0/bin/linux/amd64/kubectl
|
||||
curl -sSL https://get.helm.sh/helm-v3.16.4-linux-amd64.tar.gz | tar xz -O linux-amd64/helm > "$bin/helm"
|
||||
curl -sSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xz -O crane > "$bin/crane"
|
||||
chmod +x "$bin"/{kubectl,helm,crane}
|
||||
echo "$bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The cluster's API and its registry are only reachable through the Fedora
|
||||
# host, so forward both to the runner. 30141 is the openbaar portal, for
|
||||
# the smoke at the end.
|
||||
- name: Tunnel the Talos API + registry through the Fedora host
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.TALOS_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TALOS_VM_IP:=192.168.122.173}"
|
||||
umask 077
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh_talos
|
||||
ssh -i ~/.ssh_talos -o StrictHostKeyChecking=no -o IdentitiesOnly=yes \
|
||||
-o ExitOnForwardFailure=yes -p 6667 -f -N \
|
||||
-L 6443:$TALOS_VM_IP:6443 \
|
||||
-L 30500:$TALOS_VM_IP:30500 \
|
||||
-L 30141:$TALOS_VM_IP:30141 \
|
||||
user@labs.respellion.tech
|
||||
|
||||
# The kubeconfig's server must be https://127.0.0.1:6443 — Talos puts
|
||||
# 127.0.0.1 in the apiserver cert SANs, so TLS verification still holds
|
||||
# through the tunnel.
|
||||
- name: Write the kubeconfig
|
||||
env:
|
||||
KUBECONFIG_B64: ${{ secrets.TALOS_KUBECONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
base64 -d <<< "$KUBECONFIG_B64" > "$RUNNER_TEMP/kubeconfig"
|
||||
echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
|
||||
kubectl --kubeconfig "$RUNNER_TEMP/kubeconfig" get nodes
|
||||
|
||||
# Idempotent; also makes a first deploy onto a bare cluster work. The
|
||||
# registry's storage is an emptyDir, so a replaced pod loses the images —
|
||||
# which the push in the next step puts back anyway.
|
||||
- name: Ensure the in-cluster registry
|
||||
run: make k8s-registry
|
||||
|
||||
# Push through the tunnel (localhost), pull from the node's own NodePort
|
||||
# (the address in the Talos registry-mirror patch) — same registry, two
|
||||
# names, so the two `make` calls get different K8S_REGISTRY values.
|
||||
- name: Build and push the images
|
||||
run: make k8s-images K8S_REGISTRY=localhost:30500
|
||||
|
||||
# k8s-reseed = seed configmaps + helm upgrade + re-run the bootstrap jobs.
|
||||
# The jobs are idempotent, and deleting them first is what keeps a changed
|
||||
# Job template from wedging the upgrade (`cannot patch … with kind Job`).
|
||||
- name: Deploy the chart
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish="${PUBLIC_DOMAIN:+--set public.domain=$PUBLIC_DOMAIN --set public.email=${PUBLIC_EMAIL:-}}"
|
||||
make k8s-reseed \
|
||||
TALOS_HOST=${TALOS_HOST:-localhost} \
|
||||
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
|
||||
K8S_SET="$publish"
|
||||
|
||||
# `dev` is a mutable tag and helm sees an unchanged pod template, so the
|
||||
# new images only land on a restart (pullPolicy is already Always).
|
||||
- name: Roll the services onto the new images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
svcs="acl domain bff event-subscriber projection-api self-service openbaar behandel beheer"
|
||||
kubectl -n big rollout restart deploy $svcs
|
||||
kubectl -n big rollout status --timeout=300s deploy $svcs
|
||||
|
||||
# Proves portal → Caddy → BFF → projection end to end. An empty register is
|
||||
# a pass; a 502 or a timeout is not.
|
||||
- name: Smoke the public register
|
||||
run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors http://localhost:30141/openbaar/register
|
||||
|
||||
# Cluster-wide, not just `big`: the first thing that can fail is the registry
|
||||
# in its own namespace, and a scheduling problem shows up in the events, not
|
||||
# in `rollout status` — which only ever says "timed out waiting".
|
||||
- name: Pods and events on failure
|
||||
if: failure()
|
||||
run: |
|
||||
kubectl get pods -A -o wide || true
|
||||
kubectl -n big get jobs || true
|
||||
kubectl get events -A --sort-by=.lastTimestamp | tail -30 || true
|
||||
@@ -57,3 +57,7 @@ vitest.config.*.timestamp*
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
__pycache__/
|
||||
TestResults/
|
||||
test-output/
|
||||
tests/e2e/playwright-report.json
|
||||
|
||||
+55
-8
@@ -199,9 +199,25 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
|
||||
|
||||
### 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 distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
|
||||
|
||||
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
|
||||
|
||||
### S-11 · Withdrawal (Flow 3)
|
||||
|
||||
@@ -223,36 +239,67 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
|
||||
|
||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||
|
||||
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||
|
||||
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||
|
||||
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
|
||||
|
||||
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
|
||||
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
|
||||
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||
|
||||
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
|
||||
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||
|
||||
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
|
||||
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
|
||||
|
||||
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
|
||||
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
|
||||
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
|
||||
|
||||
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
|
||||
|
||||
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
||||
|
||||
+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)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
|
||||
# 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
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
|
||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
||||
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
|
||||
# engine. This is the no-make / Windows-friendly path. See that file's header.
|
||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.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
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-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 k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
|
||||
|
||||
## 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).
|
||||
@@ -64,14 +64,22 @@ frontend:
|
||||
## lint: verify formatting (no changes)
|
||||
lint:
|
||||
dotnet format $(SLN) --verify-no-changes
|
||||
# Only pages in mkdocs.yml's nav are published, and mkdocs keeps a build green
|
||||
# when one is missing — so the nav is checked here rather than not at all.
|
||||
python3 infra/check-docs-nav.py
|
||||
|
||||
## build: release build
|
||||
build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
||||
# The CI reporting scripts are stdlib Python with their own assert-based self-checks (#161) — they
|
||||
# ride this lane so a broken job summary is caught by CI rather than by the next red pipeline.
|
||||
unit:
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
||||
python3 infra/test_playwright_summary.py
|
||||
python3 infra/test_portal_caddyfiles.py
|
||||
|
||||
## 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`
|
||||
@@ -93,14 +101,14 @@ mutation:
|
||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
||||
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||
smoke:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
|
||||
|
||||
## up: seed config volumes and start the full stack (use instead of bare
|
||||
## `docker compose up`, which can't self-seed the external config volumes)
|
||||
up:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
|
||||
## down: stop and remove the local stack (incl. the external config volumes)
|
||||
@@ -114,6 +122,11 @@ local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||
verify-local:
|
||||
bash infra/run-local-flow-check.sh
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
@@ -133,7 +146,7 @@ changelog:
|
||||
## 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
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
@@ -165,17 +178,53 @@ verify-bff:
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
|
||||
## Prometheus datasources) is live, against the already-running stack (S-16a).
|
||||
verify-observability:
|
||||
bash infra/run-observability-check.sh
|
||||
|
||||
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
|
||||
## (S-16b), against the already-running stack.
|
||||
verify-tracing:
|
||||
bash infra/run-tracing-check.sh
|
||||
|
||||
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
|
||||
## signals (S-16c), against the already-running stack.
|
||||
verify-metrics:
|
||||
bash infra/run-metrics-check.sh
|
||||
|
||||
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
|
||||
## (S-18a), against the already-running stack.
|
||||
verify-objecttypen:
|
||||
bash infra/run-objecttypen-check.sh
|
||||
|
||||
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
|
||||
## trusts the Objecttypen API (S-18b), against the already-running stack.
|
||||
verify-objecten:
|
||||
bash infra/run-objecten-check.sh
|
||||
|
||||
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
|
||||
## Objecttypen API (S-18c), against the already-running stack.
|
||||
verify-registerrecord:
|
||||
bash infra/run-registerrecord-check.sh
|
||||
|
||||
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
||||
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
||||
verify-objecten-notifications:
|
||||
bash infra/run-objecten-notifications-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
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
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-objecten-notifications-check.sh \
|
||||
&& bash infra/run-domain-check.sh \
|
||||
&& bash infra/run-bff-check.sh \
|
||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||
@@ -284,6 +333,98 @@ flowable-down:
|
||||
docker compose -f $(FL_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-fl-bpmn
|
||||
|
||||
# ── Kubernetes (single-node Talos) ─────────────────────────────────────────────
|
||||
# The Helm chart in infra/helm/big-reference is a port of infra/docker-compose.yml
|
||||
# (ADR-0033). Full walkthrough: docs/runbooks/kubernetes-talos.md.
|
||||
# TALOS_HOST the address the BROWSER uses — pins Keycloak's issuer and the portals'
|
||||
# OIDC authority. Use `localhost` with `make k8s-portals`: the OIDC
|
||||
# library needs crypto.subtle, which browsers only expose on a secure
|
||||
# context (https, or localhost) — see docs/runbooks/kubernetes-talos.md §5
|
||||
# K8S_REGISTRY the registry both sides use for this repo's images (see k8s-registry)
|
||||
K8S_NS ?= big
|
||||
K8S_CHART := infra/helm/big-reference
|
||||
K8S_REGISTRY ?=
|
||||
TALOS_HOST ?=
|
||||
# The images built from this repo — compose service name == image name == chart workload.
|
||||
K8S_IMAGES := acl domain bff event-subscriber projection-api self-service openbaar behandel beheer
|
||||
|
||||
## k8s-lint: render + schema-check the Helm chart (no cluster needed)
|
||||
k8s-lint:
|
||||
helm lint $(K8S_CHART)
|
||||
helm template big $(K8S_CHART) -n $(K8S_NS) --set images.registry=registry.invalid:5000 >/dev/null
|
||||
|
||||
## k8s-drift: fail if compose and the Helm chart describe different stacks
|
||||
# Compose is CI-canonical (ADR-0033) and the chart is a transcription of it; this
|
||||
# compares what each one deploys — workload names and resolved images. Needs
|
||||
# `docker compose` and `helm`, no cluster.
|
||||
k8s-drift:
|
||||
python3 infra/helm/check-drift.py
|
||||
|
||||
## k8s-registry: deploy the in-cluster image registry (NodePort 30500)
|
||||
k8s-registry:
|
||||
kubectl apply -f infra/helm/registry.yaml
|
||||
kubectl -n registry rollout status deploy/registry --timeout=180s
|
||||
|
||||
## k8s-images: build this repo's images (via compose) and push them to $(K8S_REGISTRY)
|
||||
# `docker save | crane push` rather than `docker push`: the registry speaks plain
|
||||
# HTTP, which the Docker daemon refuses without a root-level insecure-registries
|
||||
# entry, while crane just takes --insecure. Install: see docs/runbooks/kubernetes-talos.md.
|
||||
k8s-images:
|
||||
@command -v crane >/dev/null || { echo "crane not found — see docs/runbooks/kubernetes-talos.md §0" >&2; exit 2; }
|
||||
@test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=<registry host:port>" >&2; exit 2; }
|
||||
docker compose -f $(COMPOSE) build $(K8S_IMAGES)
|
||||
@tar=$$(mktemp -t rr-img-XXXX.tar); \
|
||||
for i in $(K8S_IMAGES); do \
|
||||
docker save register-referentie/$$i:dev -o $$tar; \
|
||||
crane push --insecure $$tar $(K8S_REGISTRY)/register-referentie/$$i:dev; \
|
||||
done; rm -f $$tar
|
||||
|
||||
## k8s-seed: create the ConfigMaps the chart mounts (upstream config + bootstrap scripts)
|
||||
k8s-seed:
|
||||
bash infra/helm/seed-configmaps.sh $(K8S_NS)
|
||||
|
||||
## k8s-up: seed the config and install/upgrade the release
|
||||
k8s-up: k8s-seed
|
||||
@test -n "$(TALOS_HOST)" || { echo "set TALOS_HOST=<node ip>" >&2; exit 2; }
|
||||
@test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=<registry the node can pull from>" >&2; exit 2; }
|
||||
helm upgrade --install big $(K8S_CHART) -n $(K8S_NS) --create-namespace \
|
||||
--set host=$(TALOS_HOST) --set images.registry=$(K8S_REGISTRY) $(K8S_SET)
|
||||
kubectl -n $(K8S_NS) get pods
|
||||
|
||||
## k8s-reseed: re-run the bootstrap jobs (after a database was wiped, or after
|
||||
## changing a Job in the chart — Job pod templates are immutable, so a plain
|
||||
## `helm upgrade` is rejected)
|
||||
k8s-reseed:
|
||||
kubectl -n $(K8S_NS) delete job -l app.kubernetes.io/component=init --ignore-not-found
|
||||
$(MAKE) k8s-up
|
||||
# The projection's schema is created on service start (Projection.ReadModel migrates in a
|
||||
# hosted service), so a wiped database also needs these two restarted — otherwise they keep
|
||||
# writing to a schema-less DB and fail with `relation "processed_notifications" does not exist`.
|
||||
kubectl -n $(K8S_NS) rollout restart deploy/event-subscriber deploy/projection-api
|
||||
kubectl -n $(K8S_NS) rollout status deploy/event-subscriber deploy/projection-api --timeout=180s
|
||||
|
||||
## k8s-portals: forward the browser-facing services to localhost (Ctrl-C stops them all)
|
||||
# The portals' OIDC flow needs a *secure context* for crypto.subtle (PKCE), and browsers
|
||||
# only grant that to https or localhost — a NodePort on the VM's IP is neither. Forwarding
|
||||
# to localhost on the same port numbers keeps Keycloak's pinned issuer valid. Deploy with
|
||||
# TALOS_HOST=localhost for this to line up.
|
||||
k8s-portals:
|
||||
@echo "self-service http://localhost:30140 · openbaar :30141 · behandel :30142 · beheer :30143 · keycloak :30180"
|
||||
@trap 'kill 0' INT TERM; \
|
||||
for f in self-service:30140:80 openbaar:30141:80 behandel:30142:80 beheer:30143:80 keycloak:30180:8080; do \
|
||||
svc=$${f%%:*}; rest=$${f#*:}; lport=$${rest%%:*}; rport=$${rest#*:}; \
|
||||
kubectl -n $(K8S_NS) port-forward --address 127.0.0.1 svc/$$svc $$lport:$$rport >/dev/null & \
|
||||
done; wait
|
||||
|
||||
## k8s-down: uninstall the release (database PVCs are kept)
|
||||
k8s-down:
|
||||
helm uninstall big -n $(K8S_NS)
|
||||
|
||||
## k8s-purge: uninstall AND drop the namespace, including the database volumes
|
||||
k8s-purge:
|
||||
-helm uninstall big -n $(K8S_NS)
|
||||
kubectl delete namespace $(K8S_NS) --ignore-not-found
|
||||
|
||||
## help: list available targets
|
||||
help:
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
:80 {
|
||||
# Same-origin API: behandelaars authenticate against the medewerker realm; the BFF validates it
|
||||
# for /behandel/* (S-12c).
|
||||
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
|
||||
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
|
||||
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
|
||||
#
|
||||
# No `resolver` stanza is needed: Caddy dials the upstream per
|
||||
# request through the system resolver, so it starts before the BFF is up, picks up
|
||||
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
|
||||
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
|
||||
handle /behandel/* {
|
||||
reverse_proxy bff:8080
|
||||
}
|
||||
|
||||
# The Angular app. Client-side routing: an unknown path serves index.html.
|
||||
handle {
|
||||
root * /usr/share/caddy
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Multi-stage build for the behandel portal (Angular → Caddy).
|
||||
# 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 caddy:2-alpine AS runtime
|
||||
COPY apps/behandel/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /src/dist/apps/behandel/browser /usr/share/caddy
|
||||
# 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).
|
||||
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
|
||||
|
||||
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,82 @@
|
||||
{
|
||||
"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,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"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 Caddy 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,198 @@
|
||||
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 { WERKBAK_REFRESH_MS, 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('picks up a newly submitted registration without a reload', async () => {
|
||||
// S-26 (#162): a registration reaches Beoordelen asynchronously, after the citizen supplies
|
||||
// documents — so the werkbak must refresh itself rather than wait for the behandelaar to reload.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const getBehandelWerkbak = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of([sample[0]]))
|
||||
.mockReturnValue(of(sample));
|
||||
const { providers } = setup({ getBehandelWerkbak });
|
||||
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||
|
||||
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||
expect(screen.queryByText('reg-2')).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||
detectChanges();
|
||||
|
||||
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText('reg-2')).toBeTruthy();
|
||||
// A background refresh must not flash the loading state over the rows the behandelaar is reading.
|
||||
expect(screen.queryByText(/bezig met laden/i)).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the rows on screen when a background refresh fails', async () => {
|
||||
// A blip on a background poll must not replace the list with the load-failure alert; the next
|
||||
// tick recovers. Only the first load speaks for whether the werkbak is readable at all.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const getBehandelWerkbak = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of(sample))
|
||||
.mockReturnValue(throwError(() => new Error('503')));
|
||||
const { providers } = setup({ getBehandelWerkbak });
|
||||
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||
|
||||
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||
detectChanges();
|
||||
|
||||
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops refreshing once the page is destroyed', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { getBehandelWerkbak, providers } = setup();
|
||||
const { fixture } = await render(WerkbakPage, { providers });
|
||||
|
||||
fixture.destroy();
|
||||
vi.advanceTimersByTime(WERKBAK_REFRESH_MS * 3);
|
||||
|
||||
expect(getBehandelWerkbak).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears a load failure once a refresh succeeds', async () => {
|
||||
// Without this the werkbak stays stuck on the error until the behandelaar reloads — the very
|
||||
// thing this slice removes. A recovered read must put the rows back.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const getBehandelWerkbak = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(throwError(() => new Error('503')))
|
||||
.mockReturnValue(of(sample));
|
||||
const { providers } = setup({ getBehandelWerkbak });
|
||||
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||
|
||||
expect(screen.getByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||
detectChanges();
|
||||
|
||||
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
|
||||
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
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,96 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { interval } from 'rxjs';
|
||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* How often an open werkbak re-reads itself (S-26/#162, ADR-0032). Exported so the spec advances the
|
||||
* clock by exactly one interval instead of hard-coding the number.
|
||||
*/
|
||||
export const WERKBAK_REFRESH_MS = 5_000;
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* The page also re-reads itself every {@link WERKBAK_REFRESH_MS} while it is open, so a registration
|
||||
* that reaches beoordeling after the behandelaar opened the werkbak shows up on its own — no reload
|
||||
* (S-26/#162). Polling rather than a pushed stream: nothing notifies the BFF either, so a stream
|
||||
* would poll the domain in the BFF instead and add connection state for the same freshness (ADR-0032).
|
||||
*/
|
||||
@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();
|
||||
// ponytail: a fixed interval, polled while the page lives — it keeps refreshing in a background
|
||||
// tab. Gate on `document.visibilityState` if the request volume ever matters.
|
||||
interval(WERKBAK_REFRESH_MS)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(() => this.load({ background: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the werkbak. A `background` read is the interval refresh: it leaves the rows and the states
|
||||
* the behandelaar is looking at alone until it has an answer — no loading flash on every tick, and
|
||||
* a blip does not swap the list for the failure alert (the next tick recovers). Only a foreground
|
||||
* read — on open, or after a decision — speaks for whether the werkbak is readable at all.
|
||||
*/
|
||||
load(options: { background?: boolean } = {}): void {
|
||||
const background = options.background ?? false;
|
||||
if (!background) {
|
||||
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);
|
||||
// A read that came back is the answer, so a refresh also clears an earlier failure — the
|
||||
// werkbak recovers on its own instead of showing the error until someone reloads.
|
||||
this.failed.set(false);
|
||||
},
|
||||
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||
error: () => {
|
||||
if (background) return;
|
||||
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,21 @@
|
||||
:80 {
|
||||
# Same-origin API: beheerders use the same medewerker realm as behandel (S-15a).
|
||||
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
|
||||
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
|
||||
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
|
||||
#
|
||||
# No `resolver` stanza is needed: Caddy dials the upstream per
|
||||
# request through the system resolver, so it starts before the BFF is up, picks up
|
||||
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
|
||||
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
|
||||
handle /beheer/* {
|
||||
reverse_proxy bff:8080
|
||||
}
|
||||
|
||||
# The Angular app. Client-side routing: an unknown path serves index.html.
|
||||
handle {
|
||||
root * /usr/share/caddy
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Multi-stage build for the beheer portal (Angular → Caddy).
|
||||
# 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/beheer apps/beheer
|
||||
COPY libs libs
|
||||
RUN pnpm nx build beheer
|
||||
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY apps/beheer/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /src/dist/apps/beheer/browser /usr/share/caddy
|
||||
# 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).
|
||||
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
|
||||
|
||||
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,82 @@
|
||||
{
|
||||
"name": "beheer",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"sourceRoot": "apps/beheer/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular/build:application",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/beheer",
|
||||
"browser": "apps/beheer/src/main.ts",
|
||||
"tsConfig": "apps/beheer/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/beheer/public"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/beheer/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": "beheer:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "beheer:build:development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/web:file-server",
|
||||
"options": {
|
||||
"buildTarget": "beheer:build",
|
||||
"staticFilePath": "dist/apps/beheer/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,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 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 beheer 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('beheer 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 catalogus call', () => {
|
||||
bff.getBeheerCatalogiZaaktypen().subscribe();
|
||||
|
||||
const req = http.expectOne('/beheer/catalogi/zaaktypen');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
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 Caddy proxy) — the interceptor matches on
|
||||
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
|
||||
*/
|
||||
export const SECURE_API_ROUTES = ['/beheer/'];
|
||||
|
||||
/**
|
||||
* 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,5 @@
|
||||
<nav aria-label="Beheer" class="utrecht-theme">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||
{ path: 'default-fill', component: DefaultFillPage, 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 CatalogusPage 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 = 'beheer';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Catalogus</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
|
||||
in een latere slice.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
|
||||
opnieuw.
|
||||
</p>
|
||||
} @else if (loaded() && items().length === 0) {
|
||||
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
|
||||
} @else if (items().length > 0) {
|
||||
<table utrecht-table>
|
||||
<caption>
|
||||
Gepubliceerde zaaktypen
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Identificatie</th>
|
||||
<th scope="col">Omschrijving</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (zaaktype of items(); track zaaktype.identificatie) {
|
||||
<tr>
|
||||
<td>{{ zaaktype.identificatie }}</td>
|
||||
<td>{{ zaaktype.omschrijving }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { CatalogusPage } from './catalogus-page';
|
||||
|
||||
const sample: BeheerZaaktype[] = [
|
||||
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
|
||||
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
|
||||
];
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const getBeheerCatalogiZaaktypen =
|
||||
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
|
||||
return {
|
||||
getBeheerCatalogiZaaktypen,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('CatalogusPage', () => {
|
||||
it('lists the published zaaktypen on open', async () => {
|
||||
const { getBeheerCatalogiZaaktypen, providers } = setup();
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
|
||||
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-registratie')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
|
||||
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a load failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(CatalogusPage, { providers: setup().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 { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
|
||||
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
|
||||
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-catalogus-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './catalogus-page.html',
|
||||
})
|
||||
export class CatalogusPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly items = signal<BeheerZaaktype[]>([]);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.bff.getBeheerCatalogiZaaktypen().subscribe({
|
||||
next: (rows: BeheerZaaktype[]) => {
|
||||
this.items.set(rows);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
|
||||
error: () => {
|
||||
this.items.set([]);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
||||
voor de eerstvolgende zaak.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (loaded()) {
|
||||
<form (submit)="save(); $event.preventDefault()">
|
||||
<p>
|
||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
||||
<input
|
||||
id="bronorganisatie"
|
||||
name="bronorganisatie"
|
||||
[value]="bronorganisatie()"
|
||||
(input)="bronorganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
||||
<input
|
||||
id="verantwoordelijkeOrganisatie"
|
||||
name="verantwoordelijkeOrganisatie"
|
||||
[value]="verantwoordelijkeOrganisatie()"
|
||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
||||
<input
|
||||
id="vertrouwelijkheidaanduiding"
|
||||
name="vertrouwelijkheidaanduiding"
|
||||
[value]="vertrouwelijkheidaanduiding()"
|
||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
||||
Opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (saved()) {
|
||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
||||
}
|
||||
@if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
||||
het opnieuw.
|
||||
</p>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { DefaultFillPage } from './default-fill-page';
|
||||
|
||||
const current: BeheerDefaultFill = {
|
||||
bronorganisatie: '517439943',
|
||||
verantwoordelijkeOrganisatie: '517439943',
|
||||
vertrouwelijkheidaanduiding: 'openbaar',
|
||||
};
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBeheerDefaultFill,
|
||||
putBeheerDefaultFill,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultFillPage', () => {
|
||||
it('loads the current default-fill into the form on open', async () => {
|
||||
const { getBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
expect(bron.value).toBe('517439943');
|
||||
});
|
||||
|
||||
it('saves the edited values via the BFF', async () => {
|
||||
const { putBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
||||
);
|
||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a save failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-default-fill-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './default-fill-page.html',
|
||||
})
|
||||
export class DefaultFillPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly bronorganisatie = signal('');
|
||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly saved = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff.getBeheerDefaultFill().subscribe({
|
||||
next: (d: BeheerDefaultFill) => {
|
||||
this.bronorganisatie.set(d.bronorganisatie);
|
||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.saving.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff
|
||||
.putBeheerDefaultFill({
|
||||
bronorganisatie: this.bronorganisatie(),
|
||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.saved.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Beheerportaal 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,21 @@
|
||||
:80 {
|
||||
# Same-origin API: the public register is anonymous, but still reads through the BFF (S-09).
|
||||
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
|
||||
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
|
||||
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
|
||||
#
|
||||
# No `resolver` stanza is needed: Caddy dials the upstream per
|
||||
# request through the system resolver, so it starts before the BFF is up, picks up
|
||||
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
|
||||
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
|
||||
handle /openbaar/* {
|
||||
reverse_proxy bff:8080
|
||||
}
|
||||
|
||||
# The Angular app. Client-side routing: an unknown path serves index.html.
|
||||
handle {
|
||||
root * /usr/share/caddy
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Multi-stage build for the openbaar portal (Angular → nginx).
|
||||
# Multi-stage build for the openbaar portal (Angular → Caddy).
|
||||
# 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
|
||||
@@ -13,9 +13,9 @@ 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
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY apps/openbaar/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/caddy
|
||||
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
* same-origin as the BFF (Caddy proxies /openbaar), so the api-client's relative calls stay same-origin.
|
||||
*/
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<tbody>
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<tr>
|
||||
<td>{{ entry.id }}</td>
|
||||
<td>{{ entry.reference }}</td>
|
||||
<td>{{ entry.status }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
|
||||
import { RegisterPage } from './register-page';
|
||||
|
||||
const sample: OpenbaarEntry[] = [
|
||||
{ id: 'zaak-abc', status: 'INGEDIEND' },
|
||||
{ id: 'zaak-def', status: 'INGESCHREVEN' },
|
||||
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
|
||||
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||
];
|
||||
|
||||
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
||||
@@ -22,9 +22,11 @@ describe('RegisterPage', () => {
|
||||
await render(RegisterPage, { providers: providers(get).providers });
|
||||
|
||||
expect(get).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
|
||||
// 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(/zaak-def/)).toBeTruthy();
|
||||
expect(screen.getByText(/REG-def/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('searches by the entered term', async () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
:80 {
|
||||
# Same-origin API: the api-client uses relative URLs, so the browser calls this origin and Caddy
|
||||
# forwards to the BFF — no CORS, and the DigiD token is attached by the app interceptor
|
||||
# (S-08d/ADR-0010).
|
||||
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
|
||||
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
|
||||
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
|
||||
#
|
||||
# No `resolver` stanza is needed: Caddy dials the upstream per
|
||||
# request through the system resolver, so it starts before the BFF is up, picks up
|
||||
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
|
||||
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
|
||||
handle /self-service/* {
|
||||
reverse_proxy bff:8080
|
||||
}
|
||||
handle /openbaar/* {
|
||||
reverse_proxy bff:8080
|
||||
}
|
||||
|
||||
# The Angular app. Client-side routing: an unknown path serves index.html.
|
||||
handle {
|
||||
root * /usr/share/caddy
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Multi-stage build for the self-service portal (Angular → nginx).
|
||||
# Multi-stage build for the self-service portal (Angular → Caddy).
|
||||
# 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
|
||||
@@ -13,11 +13,12 @@ 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
|
||||
FROM caddy:2-alpine AS runtime
|
||||
COPY apps/self-service/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /src/dist/apps/self-service/browser /usr/share/caddy
|
||||
# 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
|
||||
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
|
||||
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/caddy/config.json
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface RuntimeConfig {
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
* api-client actually calls (same-origin via the Caddy 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.
|
||||
*/
|
||||
|
||||
@@ -3,9 +3,56 @@
|
||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||
|
||||
@if (submitted()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@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()) {
|
||||
|
||||
@@ -17,12 +17,29 @@ class FakeAuth extends AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
||||
function providers(
|
||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
|
||||
getCurrent = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
getCurrent,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
getSelfServiceRegistrations: getCurrent,
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -43,6 +60,21 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
|
||||
const { post, providers: p } = providers(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
// The confirmation view is restored from the in-flight registration — no submit click.
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
expect(screen.getByText(/reg-77/)).toBeTruthy();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -56,6 +88,76 @@ describe('RegistrationPage', () => {
|
||||
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.
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
||||
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type CurrentRegistration, 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).
|
||||
* 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).
|
||||
*
|
||||
* On load it asks the BFF for the caller's current open registration and restores the submitted view
|
||||
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-registration-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './registration-page.html',
|
||||
})
|
||||
export class RegistrationPage {
|
||||
export class RegistrationPage implements OnInit {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
@@ -22,6 +26,30 @@ export class RegistrationPage {
|
||||
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);
|
||||
|
||||
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||
* current open registration, or 204 (empty body) when there is none — in which case we show the
|
||||
* submit form as before. Failures are non-fatal for the same reason. */
|
||||
ngOnInit(): void {
|
||||
this.bff.getSelfServiceRegistrations().subscribe({
|
||||
next: (current: CurrentRegistration | void) => {
|
||||
if (current && current.registrationId) {
|
||||
this.reference.set(current.registrationId);
|
||||
this.submitted.set(true);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No resumable registration (or the lookup failed) — fall back to the submit form.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
@@ -39,4 +67,74 @@ export class RegistrationPage {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ A slice is done when:
|
||||
## 15. Out of scope for v1
|
||||
|
||||
- OpenMetadata data governance module (v3 slice).
|
||||
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
|
||||
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
|
||||
- Production-grade Helm chart (sketch only).
|
||||
- Multi-tenancy.
|
||||
- Real outbound notifications (email/SMS) — logged to console in v1.
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-21
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
|
||||
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
|
||||
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
|
||||
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
|
||||
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
|
||||
(§8.1, the only code that talks to ZGW).
|
||||
|
||||
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
|
||||
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
|
||||
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
|
||||
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
|
||||
|
||||
## Decision
|
||||
|
||||
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
|
||||
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
|
||||
(name) rather than by position or the eindstatus flag alone.**
|
||||
|
||||
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
|
||||
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
|
||||
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
|
||||
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
|
||||
zaaktype's single `selectielijstProcestype` so they validate on publish.
|
||||
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
|
||||
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
|
||||
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
|
||||
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
|
||||
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
|
||||
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
|
||||
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
|
||||
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
|
||||
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
|
||||
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
|
||||
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
|
||||
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
|
||||
registration that lapsed before its zaak was opened has nothing to cancel.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
|
||||
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
|
||||
the change is additive and §8 stays clean (only the ACL talks to ZGW).
|
||||
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
|
||||
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
|
||||
cancelled end-to-end.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
|
||||
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
|
||||
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
|
||||
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
|
||||
`Vervallen` resultaat) is a possible follow-up.
|
||||
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
|
||||
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
|
||||
is documented in the gateway.
|
||||
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
|
||||
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
|
||||
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
|
||||
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
|
||||
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
|
||||
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
|
||||
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
|
||||
the approval eindstatus resolution.
|
||||
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
|
||||
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||
|
||||
## Context
|
||||
|
||||
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||
could not complete the flow:
|
||||
|
||||
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||
and the openbaar register stayed empty (S-06).
|
||||
|
||||
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||
the compose file is written and cannot be a static value.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||
|
||||
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||
identical to the CI `flowable-init`. Idempotent.
|
||||
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||
(service_completed_successfully)`.
|
||||
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||
cycle with the ACL bootstrap.
|
||||
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||
in the openbaar register — the red-to-green test for all three gaps.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||
the slice's stated outcome.
|
||||
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||
image and the CI stack are untouched.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||
running with a placeholder).
|
||||
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||
idempotent re-seed reports current-IP URLs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||
browser. Rejected.
|
||||
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||
|
||||
## Context
|
||||
|
||||
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||
|
||||
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||
re-resolves.
|
||||
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||
400.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||
self-configures from the catalogus it already talks to.
|
||||
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||
covers it (§5).
|
||||
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||
URLValidator) — unchanged from before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||
exactly what S-27 was carved out to fix.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-17 (#18) · **Proposal issue:** #120
|
||||
|
||||
## Context
|
||||
|
||||
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
|
||||
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
|
||||
scan the register for inscriptions whose deadline is within the reminder window and
|
||||
remind each one.
|
||||
|
||||
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
|
||||
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
|
||||
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
|
||||
work as soon as it is parked, on a short poll interval. The reminder sweep is a
|
||||
different shape of work: **time-triggered**, once a day, over our own store — there
|
||||
is no queue to drain and no "as soon as possible" requirement.
|
||||
|
||||
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
|
||||
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
|
||||
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
|
||||
|
||||
## Decision
|
||||
|
||||
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
|
||||
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
|
||||
|
||||
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
|
||||
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
|
||||
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
|
||||
many reminders went out.
|
||||
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
|
||||
which the store query and the sweep both build on. The sweep marks each reminded
|
||||
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
|
||||
(§8.6).
|
||||
|
||||
Two options were rejected:
|
||||
|
||||
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
|
||||
drifts to process-start time, has no cron/misfire semantics, and contradicts the
|
||||
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
|
||||
for.
|
||||
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
|
||||
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
|
||||
continuously" work is churn and a boundary change for negative benefit. The
|
||||
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
|
||||
sweeps.**
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Cron scheduling with restart-stable timing and misfire handling, for free.
|
||||
- The reminder rule is one domain method, reused by the store query and the sweep;
|
||||
the scheduler owns none of the policy.
|
||||
- The reference app now demonstrates the intended Scheduler component.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
|
||||
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
|
||||
two genuinely different concerns, documented here.
|
||||
|
||||
**Follow-up**
|
||||
|
||||
- The validity term (5 years) and reminder lead time (16 weeks) are domain
|
||||
calibration knobs; promote them to beheer config (S-15) if a demo needs them
|
||||
per-catalogus.
|
||||
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
|
||||
store is a later concern if the Domain Service is scaled out.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Quartz is internal to the Domain Service and drives an application use case
|
||||
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
|
||||
peer module.
|
||||
@@ -0,0 +1,82 @@
|
||||
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16a (#122), first of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
|
||||
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
|
||||
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
|
||||
backplane must stand up first: a local, CI-friendly place for traces and metrics to
|
||||
land, viewable in one UI, reaching green health within the 3-minute compose budget.
|
||||
|
||||
Two shape decisions are non-obvious enough to record.
|
||||
|
||||
## Decision
|
||||
|
||||
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
|
||||
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
|
||||
and ship the config baked into small built images.**
|
||||
|
||||
### Trace backend: Tempo (not Jaeger)
|
||||
|
||||
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
|
||||
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
|
||||
add a second UI and a second mental model for no benefit at this scale.
|
||||
|
||||
### No OTLP collector
|
||||
|
||||
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
|
||||
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
|
||||
If we later need fan-out, tail sampling, or log processing, a collector is an
|
||||
additive change — the services already speak OTLP.
|
||||
|
||||
### Config baked into built images, not config volumes
|
||||
|
||||
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
|
||||
**verbatim** images and get their config streamed into external named volumes by
|
||||
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
|
||||
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
|
||||
are **not** peer modules we must run verbatim, so we take the simpler path: a
|
||||
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
|
||||
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
|
||||
and no Makefile sprawl.
|
||||
|
||||
### Verified, not assumed
|
||||
|
||||
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
|
||||
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
|
||||
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
|
||||
method) — so the check proves the datasources are actually wired, not merely that
|
||||
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
|
||||
itself, so no in-image healthcheck tool is required.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One UI for traces + metrics + (future) logs. Config is versioned in
|
||||
`infra/observability/` and self-contained in the images.
|
||||
- Backplane is independent of app instrumentation — #123 and #124 build on it.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Three more images built each CI run (kept small; not on the health-gate list).
|
||||
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
||||
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
||||
- Tempo runs **single-binary**, so its distributor and ingester are one process and
|
||||
some of its distributed-mode machinery is not just redundant but harmful. Its
|
||||
ingester-pool health check is disabled (`ingester_client.pool_config`) because with
|
||||
a single in-process ingester the check can never route around a failure — a 1s
|
||||
loopback-gRPC deadline missed under CI load only evicted the one ingester and made
|
||||
Tempo drop spans, which is how `verify-tracing` flaked (#156). Expect the same
|
||||
shape from other distributed-mode knobs if we tune them; the fix is to switch to
|
||||
real multi-ingester Tempo, not to re-enable them here.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The stack is passive infrastructure: services *push* OTLP and *expose*
|
||||
`/metrics`; nothing in the stack calls into a service or a peer module.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16c (#124), last of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
|
||||
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
|
||||
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
|
||||
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
|
||||
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
|
||||
|
||||
The catch: that exporter has **never had a stable release** — the whole OTel .NET
|
||||
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
|
||||
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
|
||||
a prerelease package into all five services is the decision worth recording.
|
||||
|
||||
## Decision
|
||||
|
||||
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
|
||||
services and expose `/metrics` with it.**
|
||||
|
||||
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
|
||||
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
|
||||
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
|
||||
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
|
||||
library for no gain.
|
||||
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
|
||||
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
|
||||
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
|
||||
|
||||
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
|
||||
already rejected in ADR-0023 (no collector hop). Not revisited here.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Golden-signal metrics on `/metrics` with the standard OTel names
|
||||
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
|
||||
- No collector, no bespoke exposition code.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
|
||||
CI gate; upgrading tracks the OTel core version bumps.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-0025: The BFF reads the catalogus directly from the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15a (#130), first of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
The beheer portal shows a read-only view of the ZTC catalogus (the published
|
||||
zaaktypen). Two coupling rules constrain where that data can come from:
|
||||
|
||||
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
|
||||
catalogus read *must* originate in the ACL.
|
||||
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
|
||||
through the BFF.
|
||||
|
||||
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
|
||||
out to exactly two backends — the Domain Service and the read projection. The
|
||||
catalogus is neither: it is not a registration (domain) nor a projected read model.
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
|
||||
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
|
||||
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
|
||||
|
||||
Rejected alternative — **route it through the Domain Service** (BFF → domain →
|
||||
ACL): the catalogus is not a domain concern, so the domain would gain a
|
||||
pass-through endpoint that owns no aggregate and no invariant, blurring the
|
||||
domain's responsibility purely to avoid a new edge. That is worse coupling, not
|
||||
better.
|
||||
|
||||
This adds one service-to-service edge (BFF → ACL) — an architecturally
|
||||
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
|
||||
ACL stays the only code that reads ZGW, and the portal still talks only to the
|
||||
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
|
||||
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The catalogus read follows the shortest honest path; the domain stays about
|
||||
registrations.
|
||||
- Symmetric with the other downstream clients — nothing new to learn.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on three backends instead of two. The ACL must be reachable
|
||||
for the beheer portal to load (it already is — the BFF is on the same network).
|
||||
- A second consumer of the ACL (alongside the domain and event-subscriber), so
|
||||
ACL read endpoints are now part of more than one caller's contract.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
|
||||
reason this ADR exists.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15b (#131), second of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
|
||||
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
|
||||
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
|
||||
and have the next zaak reflect them — so the defaults must become mutable at runtime.
|
||||
|
||||
Two questions: **what** is editable, and **where** the mutable state lives.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
|
||||
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
|
||||
zaak; the beheer `PUT /default-fill` replaces it.**
|
||||
|
||||
### Only the three ZGW fill fields are editable
|
||||
|
||||
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
|
||||
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
|
||||
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
|
||||
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
|
||||
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
|
||||
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
|
||||
|
||||
### In-memory, not persisted
|
||||
|
||||
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
|
||||
reverts to the configured env. That is acceptable for this reference app: the slice
|
||||
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
|
||||
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
|
||||
compose service.
|
||||
|
||||
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
|
||||
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
|
||||
audited, multi-instance config is needed — the port stays the same.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
|
||||
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Edits don't survive a restart and aren't shared across replicas (single-instance
|
||||
assumption). Documented ceiling above.
|
||||
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
|
||||
store) — a deliberate split by editability.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
|
||||
The ACL remains the owner of the ZGW-facing config.
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-27
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-18c (#141), third of the S-18 (#19) split
|
||||
|
||||
## Context
|
||||
|
||||
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
|
||||
register-record store (PRD §"Objecten as the authoritative register record store").
|
||||
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
|
||||
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
|
||||
|
||||
Objecten validates every object against a **objecttype version's JSON schema**. So the
|
||||
schema is a contract: it fixes which fields a register record may carry. The register is
|
||||
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
|
||||
disclosure boundary — anything the schema allows can end up public.
|
||||
|
||||
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
|
||||
the Objecttypen API (which has no declarative objecttype step).
|
||||
|
||||
## Decision
|
||||
|
||||
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
|
||||
public-safe fields — `id`, `status`, `reference` — and register it over the API at
|
||||
startup with a one-shot, idempotently.**
|
||||
|
||||
### The schema mirrors the BFF's public projection, not the internal one
|
||||
|
||||
The public-safe field set already exists: the BFF's `OpenbaarEntry`
|
||||
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
|
||||
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
|
||||
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
|
||||
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
|
||||
|
||||
| field | type | notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string (required) | zaak id — the entry's stable key |
|
||||
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
|
||||
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
|
||||
|
||||
`additionalProperties: false` so a record can't smuggle a field the schema didn't
|
||||
sanction, and `dataClassification: "open"` records the intent that this objecttype is
|
||||
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
|
||||
construction, so S-19 cannot write a personal-data field into the public register even by
|
||||
mistake.
|
||||
|
||||
### Registered over the API by a one-shot, not setup_configuration
|
||||
|
||||
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
|
||||
declarative step to create an objecttype with a schema. So a `registerrecord-init`
|
||||
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
|
||||
**published** version over the API once Objecttypen is healthy, following the ADR-0020
|
||||
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
|
||||
exists it is a no-op, so it is safe on every `up`.
|
||||
|
||||
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
|
||||
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
|
||||
adds v1 if none exists.
|
||||
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
|
||||
POST a new version, or move to a declarative step once the upstream supports one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The public register's disclosure surface is fixed in one reviewed artifact
|
||||
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
|
||||
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
|
||||
schema — that must be kept in sync by hand (a drift check is a candidate for later).
|
||||
- Hand-managed schema version (ceiling above).
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
|
||||
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
|
||||
@@ -0,0 +1,175 @@
|
||||
# ADR-0028: Objecten holds the register, OpenZaak holds the process
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19a (#149), first of the S-19 (#20) split
|
||||
|
||||
## Context
|
||||
|
||||
Until this slice the register existed only as a **derived** thing: the read projection
|
||||
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
|
||||
system anywhere that holds "who is registered" as a first-class record — drop the
|
||||
projection database and the only way back is to replay ZGW history and re-derive it.
|
||||
|
||||
That is the wrong shape for a register. A BIG registration is a **fact about a person**
|
||||
that outlives the case that produced it: it is looked up, corrected, superseded, and
|
||||
retained on its own schedule. The zaak that produced it is a **process record** — it
|
||||
opens, moves through statussen, and closes. Storing the fact inside the process record
|
||||
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
|
||||
lifecycles together: the register can then never be read, retained, or corrected without
|
||||
going through the case system that happened to create it.
|
||||
|
||||
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
|
||||
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
|
||||
register record lives, and who writes it.**
|
||||
|
||||
## Decision
|
||||
|
||||
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
|
||||
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
|
||||
register record.**
|
||||
|
||||
### Not zaak eigenschappen
|
||||
|
||||
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
|
||||
They inherit the zaak's lifecycle and its archiving regime, and they give the public
|
||||
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
|
||||
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
|
||||
lifecycle the zaak cannot drag around with it.
|
||||
|
||||
### The ACL writes it, not the domain or the Event Subscriber
|
||||
|
||||
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
|
||||
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
|
||||
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
|
||||
Event Subscriber write the record when it sees the status notification — would make the
|
||||
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
|
||||
removes.
|
||||
|
||||
### Two writes, converging rather than transactional
|
||||
|
||||
Approval is now two writes across two modules, so it cannot be atomic. Both are made
|
||||
idempotent instead:
|
||||
|
||||
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
|
||||
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
|
||||
existing object with that `id`, then PATCH it or POST a new one.
|
||||
|
||||
A caller that retries a half-failed approval therefore converges. This is the same
|
||||
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
|
||||
not an exception carved out for this path.
|
||||
|
||||
### The objecttype is resolved by name, lazily
|
||||
|
||||
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
|
||||
cannot be pinned in config — the ACL resolves them by the configured name
|
||||
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
|
||||
same reasoning as ADR-0021 for zaaktypen.
|
||||
|
||||
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
|
||||
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
|
||||
is not cached, so it is retried on the next approval.
|
||||
|
||||
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
|
||||
transient typed `HttpClient` — in practice one extra GET per approval against a
|
||||
neighbouring container.
|
||||
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
|
||||
if approvals ever get hot enough for that GET to matter.
|
||||
|
||||
### The objecttype's UUID is pinned, not server-assigned
|
||||
|
||||
Objecten refuses to store an object whose objecttype it has not been configured with
|
||||
(`ObjectType with url=… is not configured`), and its configuration identifies an
|
||||
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
|
||||
when the container starts, before the `registerrecord-init` one-shot has run.
|
||||
|
||||
Rather than thread a seed-time UUID from one container into another's config, the UUID is
|
||||
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
|
||||
fixed UUID (the Objecttypen API accepts a client-supplied one), and
|
||||
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
|
||||
declared up front, both stay idempotent, and neither has to wait for the other.
|
||||
|
||||
The cost is a constant duplicated across two files that must be kept in step; each carries
|
||||
a comment pointing at the other.
|
||||
|
||||
### The ACL must reach Objecttypen at the URL Objecten knows it by
|
||||
|
||||
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
|
||||
matches an incoming object's `type` against the `api_root` it was configured with. So an
|
||||
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
|
||||
URL that Objecten then rejects as "not one of the available choices" — even though it is
|
||||
the same objecttype.
|
||||
|
||||
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
|
||||
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
|
||||
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
|
||||
integration tests only pass from inside the compose network.
|
||||
|
||||
### Objecten's notifications are off for this slice
|
||||
|
||||
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
|
||||
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
|
||||
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
|
||||
|
||||
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
|
||||
no abonnement for it. Configuring only the client side would make writes succeed while
|
||||
every message was dropped on the floor — a delivery path that looks wired and isn't. So
|
||||
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
|
||||
|
||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||
register write yet.
|
||||
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
||||
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The register is a first-class record with its own schema, lifecycle and query surface,
|
||||
independent of the case that produced it.
|
||||
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
||||
discipline in projection code.
|
||||
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW —
|
||||
done in S-19b-2 (#153), ADR-0030.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Approval writes to two modules and is eventually consistent; a failure between them
|
||||
leaves a zaak in eindstatus without a register record until the approval is retried.
|
||||
Nothing repairs that automatically yet.
|
||||
- One more upstream module on the approval path, and one more dev credential
|
||||
(`Acl__Objecten__Token`) in compose.
|
||||
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
||||
name (compose + `register.py`).
|
||||
- ~~Until S-19b lands, the public register is still read from the NRC-derived projection, so
|
||||
the register record is written but not yet read — the two must agree.~~ Closed by ADR-0030:
|
||||
the projection is now derived from the register, so there is only one source to agree with.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
|
||||
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
|
||||
and no service reaches Objecten's database.
|
||||
|
||||
## Verification
|
||||
|
||||
The end-to-end assertion lives in the Playwright happy path
|
||||
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
||||
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
||||
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
|
||||
public-safe schema.
|
||||
|
||||
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
|
||||
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
|
||||
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
|
||||
calls the ACL. The e2e is the only check that drives a real approval.
|
||||
|
||||
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
||||
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
||||
pair: two writes for the same id leave exactly one object, carrying the second write's
|
||||
status and nothing outside the public-safe schema.
|
||||
|
||||
All three findings above — the pinned UUID, the notifications block, and the base-URL
|
||||
constraint — came out of running the gateway against those live modules while writing the
|
||||
slice, not out of CI.
|
||||
@@ -0,0 +1,122 @@
|
||||
# ADR-0029: Objecten publishes register events to NRC
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
||||
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
||||
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
||||
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
||||
turning the client side on alone would have produced a delivery path that looks wired and
|
||||
drops every message.
|
||||
|
||||
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
||||
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
||||
builds the four missing pieces and lifts the ceiling.
|
||||
|
||||
## Decision
|
||||
|
||||
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
||||
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
||||
exactly as ADR-0007 did for OpenZaak.**
|
||||
|
||||
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
||||
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
||||
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
||||
alongside `zaken`.
|
||||
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
||||
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
||||
`objecten-redis` db 1 (db 0 is already the cache).
|
||||
|
||||
### One NRC, one credential, one kanaal per publisher
|
||||
|
||||
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
||||
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
||||
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
||||
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
||||
reference application has no use for.
|
||||
|
||||
The kanaal name is **not ours to choose**: the Objects API sends
|
||||
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
||||
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
||||
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
|
||||
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
|
||||
|
||||
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
||||
|
||||
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
|
||||
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
|
||||
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
|
||||
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
|
||||
|
||||
```
|
||||
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
|
||||
```
|
||||
|
||||
which `objecten-celery` then retries with exponential backoff, forever, in the background —
|
||||
the write itself having returned 201.
|
||||
|
||||
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
|
||||
caller side: the `objecten` service carries an `objecten.local` network alias, and every
|
||||
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
|
||||
integration tests, this slice's verify check — addresses it there. An alias rather than a
|
||||
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
|
||||
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
|
||||
unaffected and keep using the plain service name.
|
||||
|
||||
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
|
||||
match Objecten's configured `api_root`": these modules put request-derived hosts into data
|
||||
another module then validates or dereferences.
|
||||
|
||||
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
|
||||
and silently no notification.
|
||||
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
|
||||
so the plain name stops working, rather than adding a lint.
|
||||
|
||||
### A worker, not a synchronous send
|
||||
|
||||
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
||||
the task sits in redis forever and every register write is silently undelivered — the exact
|
||||
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
||||
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
||||
|
||||
## Verification
|
||||
|
||||
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
||||
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
||||
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
||||
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
||||
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
||||
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
||||
config.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
||||
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
||||
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
|
||||
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
|
||||
now touches two modules.
|
||||
- Objecten now has two in-network names, and which one a caller uses silently decides
|
||||
whether its writes are notified (ceiling above).
|
||||
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
|
||||
is visible only in the worker log.
|
||||
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
|
||||
read NRC's own delivery admin rather than building a retry layer here.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. This is infrastructure between two upstream modules, over their documented
|
||||
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
||||
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
||||
@@ -0,0 +1,141 @@
|
||||
# ADR-0030: The read projection is sourced from the register, not from ZGW
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-28
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19b-2 (#153), second of the S-19b (#150) split
|
||||
- **Builds on:** ADR-0008 (read projection store), ADR-0028 (Objecten holds the register), ADR-0029 (Objecten publishes to NRC)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0028 moved the authoritative register record into the Objecten API, and said what should
|
||||
follow: "the read projection can become a cache of Objecten rather than a re-derivation of
|
||||
ZGW." Until this slice it was still the latter — the Event Subscriber listened on the `zaken`
|
||||
kanaal and inferred register state from case events:
|
||||
|
||||
- a `zaak`/`create` meant INGEDIEND;
|
||||
- any `status`/`create` was taken to be the approval, so meant INGESCHREVEN — the subscriber
|
||||
may not read OpenZaak (§8.1), so it could not tell one statustype from another;
|
||||
- the citizen-facing reference was not in the notification at all, so every projection had a
|
||||
second hop: ask the ACL for the zaak's identificatie (#78).
|
||||
|
||||
So the register — a fact about a person — was reconstructed by guessing at the lifecycle of the
|
||||
case that happened to produce it. ADR-0029 made the register itself publish. This ADR switches
|
||||
the projection over to it.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Event Subscriber listens on the `objecten` kanaal and projects the `RegisterRecord` the
|
||||
notification points at. The projection is a cache of the register; ZGW is no longer a source.**
|
||||
|
||||
- The subscriber's abonnement moves from `zaken` to `objecten` (`register-abonnement.py`, and
|
||||
the CI projection check).
|
||||
- An Objecten notification carries **no record data** — only the object URL and the objecttype
|
||||
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
|
||||
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
|
||||
to it.
|
||||
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
|
||||
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
|
||||
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
|
||||
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
|
||||
one state change this slice exists to project. `destroy` is deliberately not accepted:
|
||||
removing a registration from the public register is its own decision.
|
||||
- The record already carries `id`, `status` and `reference`, so the row is the record. The
|
||||
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
|
||||
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
|
||||
`ObjectUrl`, and the ACL enrichment hop disappears.
|
||||
|
||||
### The ACL writes an INGEDIEND record on submit
|
||||
|
||||
Before this slice only approval wrote a record, so re-sourcing alone would have silently
|
||||
dropped every INGEDIEND row from the public register. `OpenZaakAsync` therefore upserts a
|
||||
record with status INGEDIEND after opening the zaak, keyed on the same zaak id that approval
|
||||
later upserts to INGESCHREVEN.
|
||||
|
||||
This is the same two-writes-converging posture ADR-0028 already accepted for approval, now on
|
||||
the submit path too: both writes are idempotent, so a retried submit updates the record rather
|
||||
than adding a second one (§8.6). The reference comes from the registration itself, so unlike
|
||||
approval this path needs no ZGW read-back.
|
||||
|
||||
The alternative — a register holding only INGESCHREVEN — is arguably the more correct reading
|
||||
of "public register", but it narrows what the openbaar portal shows and reads against PRD §68
|
||||
("~50 register entries with diverse statuses"). Rejected as a behaviour change this slice was
|
||||
not asked to make.
|
||||
|
||||
### The dedup key is the projected row, not the notification
|
||||
|
||||
NRC carries no notification id and may redeliver, so the idempotency key is derived from
|
||||
content (as before). The obvious candidates both break here:
|
||||
|
||||
- **the object URL alone** — the ACL upserts *one object per registration*, so submit and
|
||||
approval notify about the same URL, and the approval would be swallowed as a duplicate;
|
||||
- **object URL + actie** — a retried approval is a second `update`, so it would be dropped
|
||||
while genuinely being the same state (harmless), but a *third* distinct state would collide
|
||||
with it (not harmless).
|
||||
|
||||
The key is therefore the object plus the state that write puts in the projection —
|
||||
`objecten:object:{url}:{status}:{reference}`. A redelivery collapses; a genuine state change
|
||||
does not. That is exactly the property §8.6 asks for, and it needs no version field from
|
||||
Objecten's internals.
|
||||
|
||||
### The notification log holds the row, not the event
|
||||
|
||||
`processed_notifications` stops describing ZGW events (`actie`, `zaak_id`, `resource`) and
|
||||
holds the projected row itself (`register_id`, `status`, `reference`). A rebuild becomes a
|
||||
replay with no mapping rules and no upstream reads at all — §8.4 held before via the ACL hop;
|
||||
now it holds outright.
|
||||
|
||||
The migration **drops** the old columns rather than renaming them. EF scaffolded renames
|
||||
(`resource` → `register_id`, `zaak_id` → `status`) that would have carried ZGW values into
|
||||
columns meaning something else entirely, and a rebuild would then have projected that garbage.
|
||||
|
||||
- ponytail ceiling: the migration empties both tables. A pre-slice row describes a zaak event
|
||||
the new projector cannot reproject, and the registrations behind those rows have no
|
||||
RegisterRecord in Objecten (only approvals wrote one), so they are not re-derivable from the
|
||||
new source either.
|
||||
- Upgrade path: fine while stacks are ephemeral. If a long-lived environment ever needs to keep
|
||||
them, backfill by walking Objecten's objects rather than replaying the log.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The register is read from the register. The projection is a derived cache of a first-class
|
||||
record, not an inference over someone else's lifecycle.
|
||||
- The "any status-create is the approval" guess is gone — a real source of wrongness the moment
|
||||
the zaaktype grows a second statustype.
|
||||
- One hop fewer per notification: the record carries its own reference, so the ACL enrichment
|
||||
call disappears.
|
||||
- A rebuild needs nothing but its own log (§8.4).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Submission is now two writes across two modules and eventually consistent. A failure between
|
||||
them leaves a zaak with no register record until the submit is retried; nothing repairs that
|
||||
automatically yet — the same gap ADR-0028 recorded for approval, now on a second path.
|
||||
- The projection lags the register by a notification round trip, where it used to lag the zaak
|
||||
by one. In practice the same order of magnitude.
|
||||
- Projecting now depends on the ACL being reachable, where the reference enrichment used to be
|
||||
the only ACL dependency. A failed read means the notification is not logged and not
|
||||
projected — NRC retries, so it converges, but the failure mode is now on the main path.
|
||||
- OpenZaak still publishes to `zaken` and nothing in the product listens. Kept because the
|
||||
`verify-nrc` check asserts that path, and turning off a working publisher to save nothing
|
||||
would be its own risk.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. §8.1 holds — the subscriber reaches Objecten only through the ACL. §8.4 is
|
||||
strengthened: the projection is rebuildable from its own log, with no upstream reads at all.
|
||||
§8.6 is what the dedup-key discussion above is about.
|
||||
|
||||
## Verification
|
||||
|
||||
`make verify-projection` (`infra/run-projection-check.sh`, in CI's `verify-stack`) opens a zaak
|
||||
**through the ACL** and asserts projection-api serves a row for it with status INGEDIEND — the
|
||||
whole new chain in one assertion: ACL → Objecten → `objecten-celery` → NRC → `nrc-beat` →
|
||||
Event Subscriber → projection → projection-api. A zaak created behind the ACL's back produces
|
||||
no row, which is the re-source working rather than a gap.
|
||||
|
||||
`RegisterProjectieBijwerken.feature` covers the use case in business language, including the
|
||||
approval case — the same row moving INGEDIEND → INGESCHREVEN, which is now one registration's
|
||||
record being updated rather than two unrelated ZGW events.
|
||||
@@ -0,0 +1,49 @@
|
||||
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-03
|
||||
- **Slice:** S-15c (Gitea #132)
|
||||
|
||||
## Context
|
||||
|
||||
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
|
||||
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
|
||||
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
|
||||
brokers that carry their own assurance levels. A reference application that demonstrates a
|
||||
government architecture should show MFA on the staff realm.
|
||||
|
||||
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
|
||||
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
|
||||
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
|
||||
|
||||
## Decision
|
||||
|
||||
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
|
||||
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
|
||||
|
||||
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
|
||||
subflow that fires when the user has an OTP credential. Seeding the credential therefore
|
||||
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
|
||||
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
|
||||
action**, so a medewerker created later must enrol before their first login.
|
||||
|
||||
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
|
||||
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
|
||||
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
|
||||
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
|
||||
asserts that refusal, so the enforcement cannot silently regress.
|
||||
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
|
||||
form. New staff specs must use it.
|
||||
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
|
||||
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
|
||||
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
|
||||
eHerkenning at the required assurance level) and seeds no credentials at all.
|
||||
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
|
||||
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
|
||||
the required action at next login (enrol, then challenge) rather than skipping MFA — an
|
||||
acceptable equivalence for this purpose, and the reason the required action is set.
|
||||
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-04
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
|
||||
the self-service resume slice (#111), so #162 is the identifier that counts.
|
||||
|
||||
## Context
|
||||
|
||||
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
|
||||
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
|
||||
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
|
||||
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
|
||||
that is already open, and until now a behandelaar had to reload the page to see it.
|
||||
|
||||
Three forces shape the mechanism:
|
||||
|
||||
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
|
||||
task events, and there is no bus between the domain and the BFF.
|
||||
- **The BFF is stateless** and sits behind each portal's reverse proxy.
|
||||
- **This is the repo's first live-updating view**, so the choice sets a precedent.
|
||||
|
||||
## Decision
|
||||
|
||||
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
|
||||
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
|
||||
state.**
|
||||
|
||||
The refresh is a *background* read: it leaves the rows and the loading/failure states
|
||||
untouched until it has an answer, so a tick never flashes a spinner over rows a
|
||||
behandelaar is reading and a single failed poll never swaps the list for the error alert.
|
||||
A read that comes back also clears an earlier failure, so the view recovers on its own —
|
||||
the same reload this slice set out to remove would otherwise be needed to escape a
|
||||
transient error. Only a foreground read (on open, after a decision) speaks for whether the
|
||||
werkbak is readable at all.
|
||||
|
||||
### Why not SSE or WebSockets
|
||||
|
||||
Neither buys freshness here, because **nothing notifies the BFF either**:
|
||||
|
||||
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
|
||||
domain and forwards diffs — the same latency, plus connection lifecycle, proxy
|
||||
buffering, and auth on a long-lived connection.
|
||||
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
|
||||
sticky-session-bound. A genuine push path would *also* need the domain to publish task
|
||||
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
|
||||
is none of those.
|
||||
|
||||
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
|
||||
for review") in a handful of lines inside one component.
|
||||
|
||||
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
|
||||
background tab. Each tick costs one Flowable task query plus a store read per open task.
|
||||
- Upgrade path: publish task events from the domain, then swap the component's `interval`
|
||||
for a stream. The endpoint contract and the component's rendering stay as they are;
|
||||
gate on `document.visibilityState` first if request volume is the concern.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
|
||||
service boundary moves.
|
||||
- Self-healing: a transient read failure no longer strands the view until a manual reload.
|
||||
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
|
||||
page, which is itself the live-refresh assertion.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Staleness is bounded by one interval (≤5 s) rather than instant.
|
||||
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
|
||||
- The precedent is polling; a future view with genuinely high-frequency updates will have
|
||||
to revisit this (see the upgrade path above).
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
|
||||
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR-0033: Kubernetes deployment is one values-driven Helm chart, not a chart per service
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-04
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** #25 (S-24) — raised directly as a deployment-target request and matched to
|
||||
that issue afterwards; see the "Process note" at the end
|
||||
|
||||
## Context
|
||||
|
||||
The stack is defined once, in `infra/docker-compose.yml`: 30-odd containers made of six
|
||||
upstream Common Ground modules (OpenZaak, Open Notificaties, Objecten, Objecttypen,
|
||||
Keycloak, Flowable), their databases and workers, five .NET services, four portals, six
|
||||
one-shot bootstrap containers, and an observability backplane (off by default here). Compose is the
|
||||
CI-canonical stack: `make verify` and every `verify-*` script drive it.
|
||||
|
||||
We now also want the stack on Kubernetes — first target a **single-node Talos VM on a
|
||||
laptop**. Four properties of this particular stack shape the answer:
|
||||
|
||||
- **The upstream images are used verbatim** and read their configuration from a mounted
|
||||
directory (`setup_configuration/data.yaml`, Keycloak realm exports, BPMN/DMN). Compose
|
||||
streams those files into external volumes (`infra/seed-config.sh`) because bind mounts
|
||||
don't reach sibling containers on the CI runner. Kubernetes needs the same files as
|
||||
ConfigMaps — from *somewhere*.
|
||||
- **Django's `URLValidator` rejects single-label hosts.** Compose works around it by
|
||||
handing the ACL and the seeds a container *IP* (ADR-0009, ADR-0020, ADR-0029, and the
|
||||
`objecten.local` network alias). In Kubernetes a Service FQDN is already multi-label, so
|
||||
the workaround has a natural replacement — but the hosts have to line up exactly, since
|
||||
Objecten reflects the request Host into the URLs it publishes to NRC.
|
||||
- **The OIDC issuer must be one string** for both the browser and the BFF (ADR-0010).
|
||||
`infra/host-browser.yml` already solved this for a host browser: pin `KC_HOSTNAME`, keep
|
||||
backchannel discovery in-cluster, and mount a `config.json` per portal.
|
||||
- **Nothing here is highly available.** One replica of everything, on one node.
|
||||
|
||||
## Decision
|
||||
|
||||
**One chart — `infra/helm/big-reference` — whose `values.yaml` is a near-literal
|
||||
transcription of the compose file, rendered by three generic templates (Deployment, Job,
|
||||
Service) over a `workloads` map.** Adding a service is a values edit.
|
||||
|
||||
Consequences of that shape, each chosen deliberately:
|
||||
|
||||
- **Config files are not copied into the chart.** `infra/helm/seed-configmaps.sh` creates
|
||||
the ConfigMaps from the files that already live in the repo — the Kubernetes sibling of
|
||||
`infra/seed-config.sh`. The chart therefore needs `make k8s-seed` before `helm install`,
|
||||
which is the same two-step dance compose already has.
|
||||
- **Bootstrap one-shots become Jobs, with no ordering mechanism.** Every one is idempotent
|
||||
(ADR-0020); each waits for the TCP ports it needs via a busybox init container and
|
||||
Kubernetes retries the rest. `make k8s-reseed` re-runs them.
|
||||
- **The four Django services apply their own `setup_configuration`** —
|
||||
`args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]` — instead of getting a
|
||||
separate `*-init` Job like compose. Both of those image scripts run
|
||||
`manage.py migrate`, and compose serialises them with
|
||||
`depends_on: service_completed_successfully`; Kubernetes has no such edge, so a Job and
|
||||
its web pod migrate the same database concurrently and Django dies with
|
||||
*"relation zgw_consumers_service already exists"*. Running the two steps in order inside
|
||||
the one container leaves exactly one migrator per database, and deletes four workloads.
|
||||
- **`args`, never `command`.** Compose's `command:` replaces the image's CMD; Kubernetes'
|
||||
`command:` replaces its ENTRYPOINT. Transcribing one to the other silently broke every
|
||||
upstream image that relies on its entrypoint — postgres ran as root and refused to
|
||||
start, Keycloak tried to exec `start-dev` as a binary. The chart now `fail`s at render
|
||||
time if a workload sets `command`, because the symptom (a crashloop three layers down)
|
||||
is nothing like the cause.
|
||||
- **Published ports are NodePorts.** No ingress controller, no LoadBalancer, no TLS. The
|
||||
four portals are the exception in *use*, not in wiring: PKCE needs `crypto.subtle`, which
|
||||
browsers expose only in a secure context, so a portal has to be reached over `localhost`
|
||||
(`make k8s-portals` forwards them) or eventually over HTTPS. `.Values.host` is therefore
|
||||
"the address the browser uses", not "the node's address" — it pins Keycloak's issuer and
|
||||
each portal's `config.json`, and both must agree with the URL bar (ADR-0010).
|
||||
- **Databases are `emptyDir` by default**, so the stack comes up on a cluster with no CSI
|
||||
driver; setting `persistence.storageClass` switches every database to a PVC.
|
||||
- **Only two hosts become FQDNs** — OpenZaak (for the ACL and the zaaktype seed) and
|
||||
Objecten (for the ACL's register writes), the two that Django validates as URLs.
|
||||
Everything else keeps the short compose service name, because the upstream
|
||||
`setup_configuration` files name those and Objecten matches an objecttype URL against the
|
||||
one it was configured with. The portals used to be a third case — nginx's `resolver` never
|
||||
appends search domains, so the bare `bff` upstream could not resolve on Kubernetes — which
|
||||
ADR-0034 removed by serving them with Caddy, whose resolver honours `/etc/resolv.conf`.
|
||||
- **Compose stays CI-canonical.** The chart is a second deployment target, not a
|
||||
replacement; the acceptance, verify and e2e lanes are unchanged.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **A chart per service, or an umbrella of 30 subcharts.** The conventional layout, and
|
||||
roughly 1,500 lines of near-identical YAML for a stack where 28 of 30 workloads are
|
||||
"one pod, one image, some env". It buys independent versioning we don't want (the stack
|
||||
is demoed as a whole) and costs the eye-diffability against the compose file that keeps
|
||||
the two stacks honest.
|
||||
- **`kompose convert`.** One-shot generation, no ongoing artefact to maintain — but it
|
||||
drops exactly the parts that carry the design (init ordering, the config volumes, the
|
||||
issuer pinning) and produces output nobody owns.
|
||||
- **Bitnami PostgreSQL/Redis subcharts.** Six more dependencies (CLAUDE.md §13) and a
|
||||
second way of expressing the same three-line database.
|
||||
- **ingress-nginx with hostname routing.** Needs a controller, `/etc/hosts` entries and a
|
||||
matching issuer host; NodePorts need none of it and reuse the mechanism
|
||||
`infra/host-browser.yml` already proves.
|
||||
- **A registry on the laptop** (the obvious home for images built there). Talos cannot
|
||||
side-load an image, so a registry is required either way — but reaching one on the host
|
||||
means opening an inbound port on firewalld's `libvirt` zone, which needs root, and
|
||||
pushing to it over plain HTTP means an `insecure-registries` entry in the Docker daemon,
|
||||
which needs root again. `infra/helm/registry.yaml` runs the registry *in* the cluster on
|
||||
a NodePort instead: pushing laptop → node is outbound and unfiltered, the node pulls from
|
||||
its own NodePort, and `docker save | crane push --insecure` needs no daemon
|
||||
configuration. Cost: one more (throwaway, `emptyDir`) workload, and a re-push if its pod
|
||||
is replaced.
|
||||
- **Helm hooks (`pre-install`/`post-install`) for bootstrap ordering.** Hooks run after
|
||||
`--wait`, which would deadlock: OpenZaak's readiness needs the migrations that the hook
|
||||
is supposed to run. Idempotent Jobs plus retries need no such sequencing.
|
||||
|
||||
- ponytail ceiling: single-node assumptions are baked in — one replica per workload,
|
||||
`Recreate` rollouts, ReadWriteOnce volumes, no PodDisruptionBudgets, no resource
|
||||
requests or limits (a laptop VM schedules everything or nothing), plain HTTP.
|
||||
Upgrade path for a real cluster: add requests/limits per workload (the field is already
|
||||
passed through), swap NodePorts for an Ingress with TLS, and give the databases a real
|
||||
StorageClass — none of which changes the workload graph.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One file to read to see what the cluster runs, and it lines up with the compose file
|
||||
line for line.
|
||||
- The compose IP workarounds disappear: cluster DNS supplies multi-label hosts.
|
||||
- `make k8s-lint` renders and schema-checks the whole stack without a cluster.
|
||||
- The config inputs have exactly one home (the repo) for both stacks — no fork to drift.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A second deployment description to keep in step with compose. `make k8s-drift` (#168)
|
||||
now enforces the part that bites — the workload set and the resolved images, with the
|
||||
four deviations below declared — but not per-workload env, ports or volumes.
|
||||
- `helm install` alone is not enough — the ConfigMaps must be seeded first, and a missing
|
||||
one surfaces as `ContainerCreating`, not as a clear error.
|
||||
- Generic templates mean a values typo can render valid-but-wrong YAML; `k8s-lint` catches
|
||||
schema errors, not intent.
|
||||
- The verify/e2e lanes do not run against the chart, so the Kubernetes path is verified by
|
||||
hand (docs/runbooks/kubernetes-talos.md §5) rather than by CI.
|
||||
- The chart deviates from compose in four places now (args, self-configuring Django pods,
|
||||
FQDN hosts, NodePorts). Each is forced by the platform and commented where it appears,
|
||||
but it is four more things that can drift.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The chart deploys the same graph: portals reach only the BFF (§8.3), only the ACL
|
||||
holds ZGW credentials (§8.1), only the Workflow Client talks to Flowable (§8.2), each
|
||||
service keeps its own database (§8.5). No workload gained a peer it didn't have in compose.
|
||||
|
||||
## Verified
|
||||
|
||||
Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk)
|
||||
under virt-manager: 29 pods ready and four bootstrap Jobs complete in under three minutes,
|
||||
with zero restarts, using ~4.4 GB of the VM's 10 GB. The smoke test in the runbook's §5
|
||||
walks the whole path — portal proxy → BFF → domain → Flowable → ACL → OpenZaak + Objecten →
|
||||
NRC → event-subscriber → projection → public register — plus a werkbak read with an
|
||||
MFA'd medewerker token. The browser flow itself was driven with Playwright against
|
||||
`http://localhost:30140`: secure context, PKCE, Keycloak form, login, no console errors.
|
||||
|
||||
## Process note
|
||||
|
||||
CLAUDE.md §14 wants the ADR proposal issue opened before the code, and §7 wants a slice
|
||||
issue behind the work. This landed the other way round — chart first, on request. The
|
||||
issue and the CI drift check are the outstanding follow-ups.
|
||||
@@ -0,0 +1,103 @@
|
||||
# ADR-0034: The portals are served by Caddy, not nginx
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-04
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** _(none yet — raised directly alongside the Kubernetes deployment, ADR-0033)_
|
||||
|
||||
## Context
|
||||
|
||||
Each portal ships as one image that does two jobs: serve the built Angular app, and
|
||||
reverse-proxy *its own* BFF endpoint group so the browser calls a single origin (no CORS,
|
||||
and the DigiD/medewerker token rides along — ADR-0010, ADR-0013). Until now that was nginx
|
||||
with a hand-written `nginx.conf` per app.
|
||||
|
||||
Two workarounds had accumulated around nginx's resolver, both for the same root cause:
|
||||
**nginx resolves a variable `proxy_pass` upstream itself**, using only the `resolver`
|
||||
directive, and never the search domains in `/etc/resolv.conf`.
|
||||
|
||||
1. `resolver 127.0.0.11` (Docker's embedded DNS) is wrong on rootless podman, which uses a
|
||||
network-specific aardvark address — so `apps/portal-nginx-resolver.sh` rewrote the
|
||||
directive at container start by reading the pod's actual nameserver.
|
||||
2. On Kubernetes the bare `bff` name cannot resolve at all without the `svc.cluster.local`
|
||||
search domain, so the same script gained a `BFF_HOST` override that the Helm chart set
|
||||
per portal (ADR-0033).
|
||||
|
||||
Both existed only to tell the proxy how to resolve one hostname.
|
||||
|
||||
## Decision
|
||||
|
||||
**Serve the portals with `caddy:2-alpine` and a small `Caddyfile` per app, replacing the
|
||||
nginx runtime stage, the four `nginx.conf` files, and the resolver workaround.**
|
||||
|
||||
Caddy dials its upstream per request through Go's resolver, which reads
|
||||
`/etc/resolv.conf` — nameserver *and* search domains. So `reverse_proxy bff:8080` resolves
|
||||
correctly under Docker, rootless podman and Kubernetes with no per-engine configuration,
|
||||
and it still starts before the BFF exists and picks up its restarts (the property the
|
||||
variable `proxy_pass` was there to buy). `apps/portal-nginx-resolver.sh`, its unit test and
|
||||
the chart's `BFF_HOST` env are deleted.
|
||||
|
||||
The Caddyfile uses `handle` blocks rather than a bare `try_files`:
|
||||
|
||||
```
|
||||
handle /behandel/* { reverse_proxy bff:8080 }
|
||||
handle { root * /usr/share/caddy; try_files {path} /index.html; file_server }
|
||||
```
|
||||
|
||||
`handle` blocks are mutually exclusive and matched most-specific-first. This matters:
|
||||
Caddy's default directive order puts rewrites (`try_files`) *before* `reverse_proxy`, so a
|
||||
top-level `try_files {path} /index.html` would rewrite every API path to `/index.html`
|
||||
before the proxy ever saw it — the SPA fallback would silently eat the API. The `handle`
|
||||
form makes the routing explicit instead of relying on directive-order trivia.
|
||||
|
||||
`infra/test_portal_caddyfiles.py` (in `make unit`) asserts each portal proxies exactly its
|
||||
own endpoint groups and keeps the SPA fallback. The four files are near-identical, so a
|
||||
copy-paste slip is cheap to make and expensive to find: proxying another portal's group
|
||||
hands a browser an endpoint its token isn't for, and the failure surfaces as a 401 three
|
||||
services away.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Keep nginx.** Zero migration, and it works — but the resolver workaround stays, and it
|
||||
had already grown a second head for Kubernetes. Both heads are nginx-specific.
|
||||
- **Keep nginx, hard-code the FQDN.** Would need a different config per deployment target
|
||||
(compose vs Kubernetes), which is exactly the fork the chart was written to avoid.
|
||||
- **Drop the proxy and use CORS.** Turns the same-origin design (ADR-0010) inside out:
|
||||
CORS preflights, an explicit origin allowlist in the BFF, and a token attached
|
||||
cross-origin. Not a serving decision — an architectural regression.
|
||||
- **Kubernetes Ingress in front of the portals.** Solves nothing about compose, adds a
|
||||
controller, and the portals would still need something to serve static files.
|
||||
|
||||
- ponytail ceiling: plain HTTP on `:80`, no compression, no cache headers beyond Caddy's
|
||||
defaults, and Caddy's automatic HTTPS deliberately unused (there is no hostname to get a
|
||||
certificate for). Upgrade path: `encode zstd gzip` and a cache policy for immutable
|
||||
Angular bundles; a real hostname makes TLS a one-line `Caddyfile` change, which is the
|
||||
main reason this is worth having in place.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One resolver behaviour across compose, podman and Kubernetes; a script, a unit test and a
|
||||
chart env var are deleted rather than maintained.
|
||||
- The images gain `curl` for free (the alpine nginx image had only busybox `wget`), which
|
||||
the compose healthchecks can use.
|
||||
- Routing intent is readable: one `handle` block per endpoint group, one for the app.
|
||||
- TLS later is a one-line change instead of a new component.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A new runtime dependency in four images (CLAUDE.md §13): Caddy replaces nginx rather than
|
||||
joining it, so the count is unchanged, but it is a less familiar config language for
|
||||
anyone who has only read nginx configs.
|
||||
- The images grew: 90.6 MB against nginx's 75.7 MB, because `caddy:2-alpine` carries a
|
||||
bigger static binary than nginx's. Measured, not estimated.
|
||||
- Caddy's directive-order rule is a genuine footgun (see above); the `handle` form and the
|
||||
Caddyfile comments exist to keep the next person out of it.
|
||||
- Any operational note that says "the portal's nginx" is now wrong; the ones in `docs/` were
|
||||
updated with this ADR.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. §8.3 is unchanged and unchanged in kind: the portals still talk only to the BFF, and
|
||||
the proxy is still the thing that makes that same-origin.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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 in
|
||||
[`../`](../adr-0001-loose-coupling.md) loopt van `adr-0001-loose-coupling` tot en met
|
||||
`adr-0010-bff-oidc`. Vandaar de eigen map `fds/`: beide reeksen beginnen bij 0001, en de nummers
|
||||
zouden anders over de volle breedte botsen.
|
||||
|
||||
In de MkDocs-navigatie staan deze zes daarom als **FDS ADR-000N**, zodat de zijbalk ze niet met de
|
||||
reeks van de applicatie verwart.
|
||||
|
||||
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
|
||||
pull request.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user