refactor: strip WP-/RB- ticket refs from backend (RD-19)

The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 21:48:08 +02:00
co-authored by Claude Sonnet 5
parent dd11eafe50
commit 8560746d15
89 changed files with 530 additions and 380 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
bin/ bin/
obj/ obj/
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data. # Runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
bigregister.db bigregister.db
bigregister.db-shm bigregister.db-shm
bigregister.db-wal bigregister.db-wal
+3 -3
View File
@@ -1,4 +1,4 @@
# WP-30: lean deployable image (optional — not used by the dev demo, which keeps the SDK # Lean deployable image (optional — not used by the dev demo, which keeps the SDK
# image in the root docker-compose.yml for `dotnet run` hot-reload). Build from the repo # image in the root docker-compose.yml for `dotnet run` hot-reload). Build from the repo
# root: `docker build -f backend/Dockerfile .` # root: `docker build -f backend/Dockerfile .`
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
@@ -9,10 +9,10 @@ RUN dotnet publish backend/src/BigRegister.Api -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app WORKDIR /app
COPY --chown=$APP_UID:$APP_UID --from=build /app . COPY --chown=$APP_UID:$APP_UID --from=build /app .
# LetterHtml.Render (WP-25) walks up from AppContext.BaseDirectory looking for a sibling # LetterHtml.Render walks up from AppContext.BaseDirectory looking for a sibling
# public/letter.css (the FE⇄BE letter contract) — this keeps that lookup working here too. # public/letter.css (the FE⇄BE letter contract) — this keeps that lookup working here too.
COPY --chown=$APP_UID:$APP_UID public ./public COPY --chown=$APP_UID:$APP_UID public ./public
# $APP_UID (uid/gid 1654, "app") is baked into this base image for exactly this purpose — # $APP_UID (uid/gid 1654, "app") is baked into this base image for exactly this purpose —
# non-root, and chown'd above so it can still create/write bigregister.db (WP-22) at /app. # non-root, and chown'd above so it can still create/write bigregister.db at /app.
USER $APP_UID USER $APP_UID
ENTRYPOINT ["dotnet", "BigRegister.Api.dll"] ENTRYPOINT ["dotnet", "BigRegister.Api.dll"]
+2 -2
View File
@@ -15,7 +15,7 @@ status codes and error envelope are production-shaped.
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
reset demo data back to empty, the same state a fresh clone starts from. This is reset demo data back to empty, the same state a fresh clone starts from. This is
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
`docs/project/backlog/WP-22-durable-persistence.md`. the durable-persistence design note in `docs/project/backlog/`.
## Run ## Run
@@ -76,7 +76,7 @@ semantics) is introduced as **`/api/v2`** served alongside v1 until clients migr
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply. - `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant. - `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
- `Intake/IntakePolicy.cs` — scholing threshold + completeness re-validation on submit - `Intake/IntakePolicy.cs` — scholing threshold + completeness re-validation on submit
(`RejectIncompleteScholing`, WP-69). (`RejectIncompleteScholing`).
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation. - `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
## Typed client (NSwag) ## Typed client (NSwag)
+17 -17
View File
@@ -1,4 +1,4 @@
# OpenZaak integration harness (WP-54) # OpenZaak integration harness
A real OpenZaak, for developing/testing the ZGW seam (`backend/src/BigRegister.Api/Zgw/`) A real OpenZaak, for developing/testing the ZGW seam (`backend/src/BigRegister.Api/Zgw/`)
against something that isn't a fixture or a stub `HttpMessageHandler`. Deliberately **not** against something that isn't a fixture or a stub `HttpMessageHandler`. Deliberately **not**
@@ -22,7 +22,7 @@ published), and one zaak (`BIG-2026-000123`) with an initiator rol for the seede
(`111222333` — the same fixture BSN `OpenZaakZaakSourceTests.cs` uses). It writes what it (`111222333` — the same fixture BSN `OpenZaakZaakSourceTests.cs` uses). It writes what it
seeded to `seeded.env` (gitignored) and prints a summary. seeded to `seeded.env` (gitignored) and prints a summary.
**Idempotent (WP-56)** — every resource is looked up by its natural key (the same field(s) **Idempotent** — every resource is looked up by its natural key (the same field(s)
OpenZaak enforces identity on: catalogus by `domein`+`rsin`, zaaktype by `catalogus`+ OpenZaak enforces identity on: catalogus by `domein`+`rsin`, zaaktype by `catalogus`+
`identificatie`, statustype by `zaaktype`+`volgnummer`, roltype by `zaaktype`+ `identificatie`, statustype by `zaaktype`+`volgnummer`, roltype by `zaaktype`+
`omschrijvingGeneriek`, zaak by `identificatie`) before creating it, so re-running against an `omschrijvingGeneriek`, zaak by `identificatie`) before creating it, so re-running against an
@@ -81,7 +81,7 @@ and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`,
`scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness `scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness
up, so it never runs where the harness doesn't exist. up, so it never runs where the harness doesn't exist.
## Notifications-enabled profile (WP-58) ## Notifications-enabled profile
The base harness above never delivers a real notification (`NOTIFICATIONS_DISABLED: 'true'`, The base harness above never delivers a real notification (`NOTIFICATIONS_DISABLED: 'true'`,
no celery worker) — fine for the read/write ZGW seam, not for proving a live webhook round-trip. no celery worker) — fine for the read/write ZGW seam, not for proving a live webhook round-trip.
@@ -137,7 +137,7 @@ already high going in; `ZGW_DEBUG_HTTP=1` on `api`, see `docker-compose.openzaak
diagnostics to help nail the cause next time it reproduces). Prints the URLs to check diagnostics to help nail the cause next time it reproduces). Prints the URLs to check
afterward and the teardown commands. afterward and the teardown commands.
Two caveats, both non-fatal (WP-60 catches and flags rather than surfacing an error): Two caveats, both non-fatal (the BFF catches and flags rather than surfacing an error):
**only `herregistratie` has a seeded zaaktype** here, so submit that wizard to prove a real **only `herregistratie` has a seeded zaaktype** here, so submit that wizard to prove a real
write; and **no Documenten content is seeded**, so a document upload's ZGW half no-ops (pick write; and **no Documenten content is seeded**, so a document upload's ZGW half no-ops (pick
"per post" in the wizard's document step, or ignore it). "per post" in the wizard's document step, or ignore it).
@@ -148,7 +148,7 @@ write; and **no Documenten content is seeded**, so a document upload's ZGW half
docker compose -f docker-compose.openzaak.yml down -v docker compose -f docker-compose.openzaak.yml down -v
``` ```
## Production (WP-55) ## Production
This dev harness stays dev-only: hardcoded `SECRET_KEY`, `POSTGRES_HOST_AUTH_METHOD=trust`, This dev harness stays dev-only: hardcoded `SECRET_KEY`, `POSTGRES_HOST_AUTH_METHOD=trust`,
`IS_HTTPS: 'no'`, a client secret checked into `setup_configuration/data.yaml`. A real `IS_HTTPS: 'no'`, a client secret checked into `setup_configuration/data.yaml`. A real
@@ -188,7 +188,7 @@ app change.
Django migrations then `setup_configuration` against `setup_configuration/data.yaml`), and Django migrations then `setup_configuration` against `setup_configuration/data.yaml`), and
`web` (the OpenZaak API on `:8000`). Pinned to `openzaak/open-zaak:1.29.1`. No `web` (the OpenZaak API on `:8000`). Pinned to `openzaak/open-zaak:1.29.1`. No
celery/celery-beat/celery-flower/nginx — trimmed for a lean, fast-booting harness; layer celery/celery-beat/celery-flower/nginx — trimmed for a lean, fast-booting harness; layer
`docker-compose.openzaak.notificaties.yml` (WP-58) on top for a real async notification `docker-compose.openzaak.notificaties.yml` on top for a real async notification
delivery round-trip. delivery round-trip.
`NOTIFICATIONS_DISABLED=true` is required, not optional: without it, OpenZaak 500s (and `NOTIFICATIONS_DISABLED=true` is required, not optional: without it, OpenZaak 500s (and
**rolls back the whole create**) on any notified resource — see the compose file's comment. **rolls back the whole create**) on any notified resource — see the compose file's comment.
@@ -197,14 +197,14 @@ app change.
one `bigregister-test` client with `heeft_alle_autorisaties: false` — this YAML mechanism one `bigregister-test` client with `heeft_alle_autorisaties: false` — this YAML mechanism
(`vng_api_common`'s `ApplicatieConfigurationModel`) has no field for granular scopes at all, (`vng_api_common`'s `ApplicatieConfigurationModel`) has no field for granular scopes at all,
so the client starts with zero Autorisaties; `bootstrap-catalogus.sh` grants the exact ones so the client starts with zero Autorisaties; `bootstrap-catalogus.sh` grants the exact ones
it needs (WP-57). it needs.
- `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration` - `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration`
has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a
live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype
needs ≥1 resultaattype and 2 statustypen before it can be published, and its needs ≥1 resultaattype and 2 statustypen before it can be published, and its
`selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same `selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same
`procesType` on the public VNG selectielijst API). Idempotent (WP-56) — see "Bring it up" above. `procesType` on the public VNG selectielijst API). Idempotent — see "Bring it up" above.
Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (WP-57, see the script's Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (see the script's
top comment): `ztc` scopes (`catalogi.lezen`/`catalogi.schrijven`, this script's own top comment): `ztc` scopes (`catalogi.lezen`/`catalogi.schrijven`, this script's own
content-creation needs) up front, `zrc` scopes (`zaken.aanmaken`/`zaken.bijwerken`/ content-creation needs) up front, `zrc` scopes (`zaken.aanmaken`/`zaken.bijwerken`/
`zaken.lezen`, scoped to the one zaaktype the BFF and this script both use) once that `zaken.lezen`, scoped to the one zaaktype the BFF and this script both use) once that
@@ -213,26 +213,26 @@ app change.
grant (scoped to a real `informatieobjecttype`, which this script would also need to seed) grant (scoped to a real `informatieobjecttype`, which this script would also need to seed)
when a later WP wires DRC content into this harness. when a later WP wires DRC content into this harness.
- **Not here**: Documenten (DRC) content, or a real Notificaties API (NRC) — add DRC content if a - **Not here**: Documenten (DRC) content, or a real Notificaties API (NRC) — add DRC content if a
later WP needs to prove that round-trip against a live instance too (WP-51 is fixture-tested later change needs to prove that round-trip against a live instance too (fixture-tested
today). A real NRC is a separate application (`open-notificaties`) this harness deliberately today). A real NRC is a separate application (`open-notificaties`) this harness deliberately
doesn't stand up — WP-58's notifications-enabled profile (below) proves live delivery without doesn't stand up — the notifications-enabled profile (below) proves live delivery without
one, since this harness only ever has one subscriber. one, since this harness only ever has one subscriber.
- `docker-compose.openzaak.notificaties.yml` (WP-58) — opt-in overlay: one celery worker for - `docker-compose.openzaak.notificaties.yml` — opt-in overlay: one celery worker for
OpenZaak (async notification delivery needs it) + `NOTIFICATIONS_DISABLED: 'false'`, joined to OpenZaak (async notification delivery needs it) + `NOTIFICATIONS_DISABLED: 'false'`, joined to
the repo root's own compose network so it can reach the `api` container by name (tried the repo root's own compose network so it can reach the `api` container by name (tried
`host.docker.internal:host-gateway` first; this environment's rootless Podman doesn't route `host.docker.internal:host-gateway` first; this environment's rootless Podman doesn't route
container→host-port traffic through it). See "Notifications-enabled profile" below. container→host-port traffic through it). See "Notifications-enabled profile" below.
- `bootstrap-notificaties.sh` (WP-58) — points OpenZaak's `NotificationsConfig` at the BFF's - `bootstrap-notificaties.sh` — points OpenZaak's `NotificationsConfig` at the BFF's
webhook via a `zgw_consumers.Service` (`update_or_create`, idempotent) instead of provisioning webhook via a `zgw_consumers.Service` (`update_or_create`, idempotent) instead of provisioning
a real NRC `abonnement`; preflights that the BFF is reachable with the right secret first a real NRC `abonnement`; preflights that the BFF is reachable with the right secret first
(a misconfigured target here means every write to a notified resource 500s and rolls back). (a misconfigured target here means every write to a notified resource 500s and rolls back).
- `verify-notificatie.sh` (WP-58) — the runnable end-to-end check: PATCHes the seeded zaak, polls - `verify-notificatie.sh` — the runnable end-to-end check: PATCHes the seeded zaak, polls
the BFF's own `/admin/audit` (WP-41) for the resulting `zgw:notificatie`/`allow` row. the BFF's own `/admin/audit` for the resulting `zgw:notificatie`/`allow` row.
- `docker-compose.openzaak.prod.yml` (WP-55) — production overrides layered on top of - `docker-compose.openzaak.prod.yml` — production overrides layered on top of
`docker-compose.openzaak.yml`: real `SECRET_KEY`/DB password/site domain/allowed-hosts from `docker-compose.openzaak.yml`: real `SECRET_KEY`/DB password/site domain/allowed-hosts from
required env vars (fails fast if unset), password DB auth instead of `trust`, `IS_HTTPS: 'yes'`. required env vars (fails fast if unset), password DB auth instead of `trust`, `IS_HTTPS: 'yes'`.
Adds no image/service of its own — see "Production" above for the full flow. Adds no image/service of its own — see "Production" above for the full flow.
- `setup_configuration/data.prod.yaml.template` (WP-55) — the prod counterpart of `data.yaml` - `setup_configuration/data.prod.yaml.template` — the prod counterpart of `data.yaml`
with no secret in it (`${OPENZAAK_CLIENT_SECRET}` etc. as placeholders); `render-prod-secrets.sh` with no secret in it (`${OPENZAAK_CLIENT_SECRET}` etc. as placeholders); `render-prod-secrets.sh`
fills it in to the gitignored `data.prod.yaml`, which the prod compose override mounts over fills it in to the gitignored `data.prod.yaml`, which the prod compose override mounts over
the container's `data.yaml`. the container's `data.yaml`.
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# WP-54 (seeding) / WP-56 (idempotency) — seeds business content # Seeds business content
# (catalogus/zaaktype/statustype/roltype/zaak/status/rol) into the OpenZaak harness started # (catalogus/zaaktype/statustype/roltype/zaak/status/rol) into the OpenZaak harness started
# by docker-compose.openzaak.yml. `setup_configuration/data.yaml` only covers infra config # by docker-compose.openzaak.yml. `setup_configuration/data.yaml` only covers infra config
# (JWTSecret + Applicatie) — confirmed by reading the installed `django_setup_configuration` # (JWTSecret + Applicatie) — confirmed by reading the installed `django_setup_configuration`
@@ -17,7 +17,7 @@
# zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for # zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for
# OpenZaakIntegrationTests.cs to assert against. # OpenZaakIntegrationTests.cs to assert against.
# #
# WP-57: `bigregister-test` starts with ZERO Autorisaties (data.yaml sets # `bigregister-test` starts with ZERO Autorisaties (data.yaml sets
# heeft_alle_autorisaties: false) — the setup_configuration YAML has no field for granular # heeft_alle_autorisaties: false) — the setup_configuration YAML has no field for granular
# scopes at all (confirmed from vng_api_common's own ApplicatieConfigurationModel), so this # scopes at all (confirmed from vng_api_common's own ApplicatieConfigurationModel), so this
# script grants them itself via `manage.py shell` (Django ORM, inside the `web` container) at # script grants them itself via `manage.py shell` (Django ORM, inside the `web` container) at
@@ -71,7 +71,7 @@ oz() {
echo "$json" echo "$json"
} }
# Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the WP-57 note up # Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the note up
# top for why this bypasses the REST Autorisaties API). $1 = component, $2 = python list # top for why this bypasses the REST Autorisaties API). $1 = component, $2 = python list
# literal of scopes, $3.. = extra `Autorisatie(...)` kwargs as `name=value` (value already a # literal of scopes, $3.. = extra `Autorisatie(...)` kwargs as `name=value` (value already a
# valid Python literal, e.g. a quoted URL). # valid Python literal, e.g. a quoted URL).
@@ -168,7 +168,7 @@ print(json.dumps({
echo " created: $zaaktype_url" echo " created: $zaaktype_url"
fi fi
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..." echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for the besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..."
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \ grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \
"zaaktype=\"$zaaktype_url\"" \ "zaaktype=\"$zaaktype_url\"" \
'max_vertrouwelijkheidaanduiding="openbaar"' 'max_vertrouwelijkheidaanduiding="openbaar"'
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# WP-58 — points OpenZaak's own NotificationsConfig straight at this repo's BFF webhook # Points OpenZaak's own NotificationsConfig straight at this repo's BFF webhook
# (POST /api/v1/zgw/notificaties, WP-52) instead of standing up a real Notificaties API (NRC) # (POST /api/v1/zgw/notificaties) instead of standing up a real Notificaties API (NRC)
# + abonnement — see docker-compose.openzaak.notificaties.yml's ponytail note for why. Requires # + abonnement — see docker-compose.openzaak.notificaties.yml's ponytail note for why. Requires
# that overlay running (adds the celery worker + flips NOTIFICATIONS_DISABLED) AND the repo # that overlay running (adds the celery worker + flips NOTIFICATIONS_DISABLED) AND the repo
# root's own `docker compose up` running (the overlay joins its `api` container's network — # root's own `docker compose up` running (the overlay joins its `api` container's network —
@@ -41,7 +41,7 @@ from zgw_consumers.models import Service
service, _ = Service.objects.update_or_create( service, _ = Service.objects.update_or_create(
slug="bff-webhook", slug="bff-webhook",
defaults=dict( defaults=dict(
label="BIG-register BFF webhook (WP-58)", label="BIG-register BFF webhook",
api_type=APITypes.orc, api_type=APITypes.orc,
api_root="$BFF_API_ROOT", api_root="$BFF_API_ROOT",
auth_type=AuthTypes.api_key, auth_type=AuthTypes.api_key,
@@ -13,7 +13,7 @@
# #
# 1. Why container-to-container instead of `http://localhost:8000`: this dev environment's # 1. Why container-to-container instead of `http://localhost:8000`: this dev environment's
# rootless Podman drops container→host-port traffic through `host.docker.internal` # rootless Podman drops container→host-port traffic through `host.docker.internal`
# (confirmed for the WP-58 notifications overlay's celery worker — DNS resolves it, every # (confirmed for the notifications overlay's celery worker — DNS resolves it, every
# TCP connect times out). # TCP connect times out).
# #
# 2. Why the ROOT project's `api` joins INTO this project's network (below), not the other way # 2. Why the ROOT project's `api` joins INTO this project's network (below), not the other way
@@ -1,9 +1,9 @@
# WP-58 — notifications-enabled overlay, layered ON TOP of docker-compose.openzaak.yml # Notifications-enabled overlay, layered ON TOP of docker-compose.openzaak.yml
# (never alone): # (never alone):
# #
# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d # docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d
# #
# The base file stays the WP-54 fast-iteration default (NOTIFICATIONS_DISABLED=true, no # The base file stays the fast-iteration default (NOTIFICATIONS_DISABLED=true, no
# worker) so nobody testing the read/write seam has to pull/boot this. This overlay flips # worker) so nobody testing the read/write seam has to pull/boot this. This overlay flips
# NOTIFICATIONS_DISABLED off and adds the one celery worker needed to actually deliver a # NOTIFICATIONS_DISABLED off and adds the one celery worker needed to actually deliver a
# notification (see base file's ponytail note). # notification (see base file's ponytail note).
@@ -14,7 +14,7 @@
# bootstrap-notificaties.sh points OpenZaak's NotificationsConfig straight at the BFF's webhook # bootstrap-notificaties.sh points OpenZaak's NotificationsConfig straight at the BFF's webhook
# instead — same delivery proof (a real write → a real HTTP POST → the BFF's audit trail), far # instead — same delivery proof (a real write → a real HTTP POST → the BFF's audit trail), far
# less harness to stand up and keep alive. Add a real NRC (+ abonnement/kanaal routing) if a # less harness to stand up and keep alive. Add a real NRC (+ abonnement/kanaal routing) if a
# later WP needs more than one subscriber or real kanaal-filtered fan-out. # later change needs more than one subscriber or real kanaal-filtered fan-out.
# #
# No celery-beat here: send_notification is a plain async task (client.post on save), not a # No celery-beat here: send_notification is a plain async task (client.post on save), not a
# scheduled one — beat only matters on a real NRC's polling side, which this harness doesn't have. # scheduled one — beat only matters on a real NRC's polling side, which this harness doesn't have.
@@ -1,4 +1,4 @@
# WP-55 — production overrides for docker-compose.openzaak.yml: real secrets, real DB auth, # Production overrides for docker-compose.openzaak.yml: real secrets, real DB auth,
# HTTPS-aware settings. Use ON TOP of the base file, never alone (it has no image/ports of its # HTTPS-aware settings. Use ON TOP of the base file, never alone (it has no image/ports of its
# own to add — see backend/openzaak/README.md for the required env vars and full flow): # own to add — see backend/openzaak/README.md for the required env vars and full flow):
# #
+3 -3
View File
@@ -1,12 +1,12 @@
# WP-54 — a real OpenZaak to develop/test the ZGW seam against, kept OUT of the root # A real OpenZaak to develop/test the ZGW seam against, kept OUT of the root
# docker-compose.yml on purpose (see backend/openzaak/README.md): OpenZaak is a full Django # docker-compose.yml on purpose (see backend/openzaak/README.md): OpenZaak is a full Django
# stack (postgres + redis), heavy compared to this repo's own FE+BFF, and nobody who isn't # stack (postgres + redis), heavy compared to this repo's own FE+BFF, and nobody who isn't
# touching the ZGW slice should have to pull/boot it. # touching the ZGW slice should have to pull/boot it.
# #
# ponytail: trimmed vs. open-zaak's own published compose — no celery/celery-beat/celery-flower # ponytail: trimmed vs. open-zaak's own published compose — no celery/celery-beat/celery-flower
# (async notification delivery, never asserted by the integration test) and no nginx (the test # (async notification delivery, never asserted by the integration test) and no nginx (the test
# hits web's port directly). Add them back only if a later WP needs an actual notification # hits web's port directly). Add them back only if a later change needs an actual notification
# round-trip against this harness (NRC delivery is already covered by fixture tests, WP-52). # round-trip against this harness (NRC delivery is already covered by fixture tests).
services: services:
db: db:
image: postgis/postgis:17-3.5 image: postgis/postgis:17-3.5
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# WP-55 — renders setup_configuration/data.prod.yaml.template into the gitignored # Renders setup_configuration/data.prod.yaml.template into the gitignored
# data.prod.yaml docker-compose.openzaak.prod.yml mounts over the container's data.yaml. # data.prod.yaml docker-compose.openzaak.prod.yml mounts over the container's data.yaml.
# Run this once before `docker compose ... up` in a production deploy; re-run whenever the # Run this once before `docker compose ... up` in a production deploy; re-run whenever the
# secrets rotate. Fails fast (no output file) if a required env var is missing — never # secrets rotate. Fails fast (no output file) if a required env var is missing — never
@@ -1,10 +1,10 @@
# Prod counterpart of data.yaml (WP-54's dev-only version, kept as-is for local iteration — # Prod counterpart of data.yaml (the dev-only version, kept as-is for local iteration —
# see docker-compose.openzaak.yml's own comment on why it hardcodes a client secret). This # see docker-compose.openzaak.yml's own comment on why it hardcodes a client secret). This
# template has no secret in it; render-prod-secrets.sh substitutes OPENZAAK_CLIENT_SECRET # template has no secret in it; render-prod-secrets.sh substitutes OPENZAAK_CLIENT_SECRET
# into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml # into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml
# mounts over the container's data.yaml. # mounts over the container's data.yaml.
# #
# Least-privilege client scopes (WP-57): heeft_alle_autorisaties is false, matching the dev # Least-privilege client scopes: heeft_alle_autorisaties is false, matching the dev
# harness (setup_configuration has no YAML field for granular `autorisaties` — see # harness (setup_configuration has no YAML field for granular `autorisaties` — see
# data.yaml's comment). This template only covers infra config; a real deploy must grant this # data.yaml's comment). This template only covers infra config; a real deploy must grant this
# client's Autorisaties the same way bootstrap-catalogus.sh does for the dev harness — via # client's Autorisaties the same way bootstrap-catalogus.sh does for the dev harness — via
@@ -2,7 +2,7 @@
# documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django # documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django
# admin. Creates the ONE application the bootstrap script + integration test authenticate as. # admin. Creates the ONE application the bootstrap script + integration test authenticate as.
# #
# heeft_alle_autorisaties is false (WP-57, least privilege) — but # heeft_alle_autorisaties is false (least privilege) — but
# `ApplicatieConfigurationModel` (vng_api_common's setup_configuration step) has no field for # `ApplicatieConfigurationModel` (vng_api_common's setup_configuration step) has no field for
# granular `autorisaties` at all, only this boolean. So this client starts with ZERO scopes; # granular `autorisaties` at all, only this boolean. So this client starts with ZERO scopes;
# bootstrap-catalogus.sh grants the exact ones it needs via `manage.py shell` (Django ORM, # bootstrap-catalogus.sh grants the exact ones it needs via `manage.py shell` (Django ORM,
@@ -12,7 +12,7 @@ sites_config_enable: true
sites_config: sites_config:
items: items:
- domain: localhost:8000 - domain: localhost:8000
name: OpenZaak (WP-54 harness) name: OpenZaak (harness)
vng_api_common_credentials_config_enable: true vng_api_common_credentials_config_enable: true
vng_api_common_credentials: vng_api_common_credentials:
@@ -26,5 +26,5 @@ vng_api_common_applicaties:
- uuid: 5a09b3c9-6a54-4b2b-8f3c-1f9b6b6a3a01 - uuid: 5a09b3c9-6a54-4b2b-8f3c-1f9b6b6a3a01
client_ids: client_ids:
- bigregister-test - bigregister-test
label: BIG-register BFF (WP-54 test harness) label: BIG-register BFF (test harness)
heeft_alle_autorisaties: false heeft_alle_autorisaties: false
+3 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# WP-58 — proves the "real write -> real webhook delivery" round-trip end-to-end: PATCHes the # Proves the "real write -> real webhook delivery" round-trip end-to-end: PATCHes the
# zaak bootstrap-catalogus.sh seeded (a notified ZRC resource), then polls the BFF's own audit # zaak bootstrap-catalogus.sh seeded (a notified ZRC resource), then polls the BFF's own audit
# trail (WP-41) for the resulting `zgw:notificatie` row. Requires bootstrap-catalogus.sh and # trail for the resulting `zgw:notificatie` row. Requires bootstrap-catalogus.sh and
# bootstrap-notificaties.sh to have already run. # bootstrap-notificaties.sh to have already run.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -29,7 +29,7 @@ jwt() {
printf '%s.%s' "$signing_input" "$sig" printf '%s.%s' "$signing_input" "$sig"
} }
echo "Triggering a real write: PATCH $ZAAK_URL (bijwerken — WP-57 granted zaken.aanmaken" echo "Triggering a real write: PATCH $ZAAK_URL (bijwerken — the client is granted zaken.aanmaken"
echo "for exactly ONE status, so a second status create 403s; a zaak update is the write this" echo "for exactly ONE status, so a second status create 403s; a zaak update is the write this"
echo "client's narrowed scope can repeat)..." echo "client's narrowed scope can repeat)..."
response=$(curl -sS -X PATCH -H "Authorization: Bearer $(jwt)" -H 'Content-Type: application/json' \ response=$(curl -sS -X PATCH -H "Authorization: Bearer $(jwt)" -H 'Content-Type: application/json' \
@@ -77,11 +77,11 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
public sealed record ChangeRequestRequest(string Telefoon); public sealed record ChangeRequestRequest(string Telefoon);
// Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry). // Authz/PII-reveal audit row — data-minimised, no PII (see AuthzAuditEntry).
public sealed record AuthzAuditDto( public sealed record AuthzAuditDto(
string At, string Action, string Resource, string Decision, string Role, string CorrelationId); string At, string Action, string Resource, string Decision, string Role, string CorrelationId);
// Feature flags (WP-47): the resolved flag set + the admin toggle body. // Feature flags: the resolved flag set + the admin toggle body.
public sealed record FeatureFlagDto(string Key, string Description, bool Enabled); public sealed record FeatureFlagDto(string Key, string Description, bool Enabled);
public sealed record SetFeatureFlagRequest(bool Enabled); public sealed record SetFeatureFlagRequest(bool Enabled);
@@ -103,7 +103,7 @@ public sealed record AanvraagSummaryDto(
string Id, string Type, AanvraagStatusDto Status, string Id, string Type, AanvraagStatusDto Status,
IReadOnlyList<string> DocumentIds, IReadOnlyList<string> DocumentIds,
string CreatedAt, string UpdatedAt, string? SubmittedAt, string CreatedAt, string UpdatedAt, string? SubmittedAt,
string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it string? Owner = null); // populated for the admin cross-owner list; the user's own list ignores it
public sealed record AanvraagDetailDto( public sealed record AanvraagDetailDto(
string Id, string Type, AanvraagStatusDto Status, string Id, string Type, AanvraagStatusDto Status,
@@ -118,7 +118,7 @@ public sealed record DraftSyncRequest(
IReadOnlyList<string>? DocumentIds = null); IReadOnlyList<string>? DocumentIds = null);
// Submit carries only the fields the server re-validates per wizard type. // Submit carries only the fields the server re-validates per wizard type.
// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by // AanvullendeScholing/ScholingPunten — intake-typed aanvragen only (gated by
// IntakePolicy.RejectIncompleteScholing's caller), null for the others. // IntakePolicy.RejectIncompleteScholing's caller), null for the others.
public sealed record AanvraagIndienenRequest( public sealed record AanvraagIndienenRequest(
string? DiplomaHerkomst = null, int? Uren = null, string? DiplomaHerkomst = null, int? Uren = null,
@@ -127,7 +127,7 @@ public sealed record AanvraagIndienenRequest(
public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status); public sealed record AanvraagIndienenResponse(string Referentie, AanvraagStatusDto Status);
// --- Beoordeling (WP-65): the behandelportal's case-detail screen. --- // --- Beoordeling: the behandelportal's case-detail screen. ---
public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName); public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName);
@@ -141,7 +141,7 @@ public sealed record BeoordelingViewDto(
IReadOnlyList<BeoordelingDocumentDto> Documenten, IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions); BeoordelingDecisionsDto Decisions);
/// Recording a decision (WP-65b). `Besluit` is the enum member name as a string — same /// Recording a decision. `Besluit` is the enum member name as a string — same
/// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum, /// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum,
/// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no /// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no
/// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting /// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting
@@ -199,7 +199,7 @@ public sealed record BriefDto(
// BIG-nummer the case screen ships masked. Status-independent, unlike the action gates. // BIG-nummer the case screen ships masked. Status-independent, unlike the action gates.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend, bool CanRevealBigNummer); public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend, bool CanRevealBigNummer);
// The brief's screen DTO also carries the org template it renders with (WP-23): // The brief's screen DTO also carries the org template it renders with:
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at // the sub-org's current PUBLISHED version — or, once sent, the version pinned at
// send time (sent letters are immutable; a republish never re-renders them). // send time (sent letters are immutable; a republish never re-renders them).
// The case this letter is about — the zorgverlener + aanvraag the behandelaar is // The case this letter is about — the zorgverlener + aanvraag the behandelaar is
@@ -220,7 +220,7 @@ public sealed record RevealBigNummerResponse(string BigNummer);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks. // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
public sealed record MeDto(IReadOnlyList<string> Capabilities); public sealed record MeDto(IReadOnlyList<string> Capabilities);
// --- Organization templates (WP-23, Brief v2 PRD §3) --- // --- Organization templates (Brief v2 PRD §3) ---
// The second template axis: appearance/identity per sub-organization (letterhead, // The second template axis: appearance/identity per sub-organization (letterhead,
// footer, signature, margins). Orthogonal to the case-type template (sections + // footer, signature, margins). Orthogonal to the case-type template (sections +
// placeholders); the two only meet at render time. // placeholders); the two only meet at render time.
@@ -43,15 +43,15 @@ public static class Mappers
/// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire /// is the one place a status has no <see cref="AanvraagStatusTag"/>, so it becomes the wire
/// convention's magic string here at the boundary rather than living inside the domain type. /// convention's magic string here at the boundary rather than living inside the domain type.
/// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers /// Shared by <see cref="ToStatusDto"/> and <c>ZgwZaakMapper</c>, so both status producers
/// agree on the projection (WP-68 F3).</summary> /// agree on the projection.</summary>
public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new( public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new(
s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden); s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden);
// Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) — // Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) —
// this is now a one-line projection of that onto the wire DTO (WP-68 F3, WP-73). // this is now a one-line projection of that onto the wire DTO.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto(); public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto();
/// <summary>SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept, /// <summary>SubmittedAt only exists once Submitted/Decided — null for a Concept,
/// same as the wire DTO's own nullable field.</summary> /// same as the wire DTO's own nullable field.</summary>
private static string? SubmittedAtOf(Aanvraag a) => a switch private static string? SubmittedAtOf(Aanvraag a) => a switch
{ {
@@ -61,7 +61,7 @@ public static class Mappers
_ => null, _ => null,
}; };
/// <summary>Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing /// <summary>Draft only exists pre-submission — null once Submitted/Decided (nothing
/// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary> /// reads it past that point; see <c>AanvraagMapper.ApplyTo</c>'s Submitted branch).</summary>
private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null; private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null;
@@ -69,10 +69,10 @@ public static class Mappers
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null). /// Admin summary — same shape plus the owner (the user-facing list leaves Owner null).
/// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by /// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by
/// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked /// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked
/// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner /// (BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// list cannot be added that forgets to. /// list cannot be added that forgets to.
public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => public static AanvraagSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) }; a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) };
@@ -5,7 +5,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The two-way seam between <see cref="AanvraagEntity"/> (the EF-mapped persistence row — /// The two-way seam between <see cref="AanvraagEntity"/> (the EF-mapped persistence row —
/// mutable, no invariants of its own, exactly the shape SQLite needs) and <see cref="Aanvraag"/> /// mutable, no invariants of its own, exactly the shape SQLite needs) and <see cref="Aanvraag"/>
/// (the closed Concept/Submitted/Decided domain union, WP-73). <see cref="ToDomain"/> is the /// (the closed Concept/Submitted/Decided domain union). <see cref="ToDomain"/> is the
/// read half: it reconstructs whichever variant a row's stored fields describe, going through /// read half: it reconstructs whichever variant a row's stored fields describe, going through
/// that variant's own constructor/required members, so a row that doesn't actually describe a /// that variant's own constructor/required members, so a row that doesn't actually describe a
/// legal aanvraag throws here rather than downstream. <see cref="ApplyTo"/>/<see cref="ToEntity"/> /// legal aanvraag throws here rather than downstream. <see cref="ApplyTo"/>/<see cref="ToEntity"/>
@@ -36,7 +36,7 @@ public static class AanvraagMapper
var submittedAt = row.SubmittedAt var submittedAt = row.SubmittedAt
?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt."); ?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt.");
// Reden wins over BesluitStatus — matches the pre-WP-73 StatusAt's own priority. In // Reden wins over BesluitStatus — matches StatusAt's own established priority. In
// practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit // practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit
// once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did, // once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did,
// the auto-reject at submission time is authoritative. // the auto-reject at submission time is authoritative.
@@ -133,7 +133,7 @@ public static class AanvraagMapper
break; break;
case Aanvraag.Submitted s: case Aanvraag.Submitted s:
// Submitted ⇒ !Draft (WP-73's Draft decision) — nothing reads a submitted aanvraag's // Submitted ⇒ !Draft (the Draft-clearing decision) — nothing reads a submitted aanvraag's
// draft (registratie/application/draft-sync.ts only ever resumes a still-Concept // draft (registratie/application/draft-sync.ts only ever resumes a still-Concept
// wizard), so this is now actually true rather than the aspirational doc-comment it // wizard), so this is now actually true rather than the aspirational doc-comment it
// used to be. // used to be.
@@ -186,11 +186,11 @@ public static class AanvraagMapper
return row; return row;
} }
/// <summary>The status at a point in time (WP-68 F3, WP-73) — pattern matching over the /// <summary>The status at a point in time — pattern matching over the
/// closed <see cref="Aanvraag"/> union, replacing the null-forgiving derefs the old flat /// closed <see cref="Aanvraag"/> union, replacing the null-forgiving derefs the old flat
/// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided /// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided
/// now, so there's nothing left to force). A recorded decision wins over the auto-approve /// now, so there's nothing left to force). A recorded decision wins over the auto-approve
/// computation, matching the pre-WP-73 priority.</summary> /// computation, matching the established priority.</summary>
public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch
{ {
Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount), Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount),
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static /// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/> /// in-memory dictionaries: <see cref="AanvraagEntity"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes /// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as /// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
/// JSON text columns rather than redesigned into relational tables — the backend /// JSON text columns rather than redesigned into relational tables — the backend
@@ -6,7 +6,7 @@ using BigRegister.Domain.Submissions;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The EF-mapped persistence row for an application (aanvraag) — WP-73 demoted this to /// The EF-mapped persistence row for an application (aanvraag) — demoted to
/// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely /// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely
/// this shape), never read or written directly outside this file. Everywhere else, production /// this shape), never read or written directly outside this file. Everywhere else, production
/// code reads and writes <see cref="Aanvraag"/> (the closed Concept/Submitted/Decided domain /// code reads and writes <see cref="Aanvraag"/> (the closed Concept/Submitted/Decided domain
@@ -32,21 +32,21 @@ public sealed class AanvraagEntity
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under /// <summary>The OpenZaak zaak's URL, set once CreateZaak registers one — null under
/// the local source. Persisted so later steps (WP-51's document→zaak link) can find it /// the local source. Persisted so later steps (the document→zaak link) can find it
/// without a network round-trip; IZaakSource.CreateZaak itself doesn't write here (the /// without a network round-trip; IZaakSource.CreateZaak itself doesn't write here (the
/// endpoint does, via <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write /// endpoint does, via <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write
/// surface at "return data", not "reach into another store".</summary> /// surface at "return data", not "reach into another store".</summary>
public string? ZaakUrl { get; set; } public string? ZaakUrl { get; set; }
/// <summary>WP-60: non-null means the ZGW side of this submit (or its document link) did not /// <summary>Non-null means the ZGW side of this submit (or its document link) did not
/// complete — the local aanvraag is authoritative and is NOT rolled back (that risks an /// complete — the local aanvraag is authoritative and is NOT rolled back (that risks an
/// orphan zaak if the failure landed after the zaak POST succeeded). The zaak, if it exists, /// orphan zaak if the failure landed after the zaak POST succeeded). The zaak, if it exists,
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path; /// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary> /// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
public string? ZgwError { get; set; } public string? ZgwError { get; set; }
/// <summary>WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the /// <summary>A behandelaar's recorded decision, if any. Non-null wins over the
/// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> — /// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> —
/// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/> /// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/>
/// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per /// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per
@@ -59,7 +59,7 @@ public sealed class AanvraagEntity
} }
/// <summary> /// <summary>
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary), /// EF Core/SQLite-backed application store (was a static Dictionary),
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite /// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
/// tolerates only one writer at a time anyway, and this was already a single /// tolerates only one writer at a time anyway, and this was already a single
/// coarse gate before the DB existed. /// coarse gate before the DB existed.
@@ -72,7 +72,7 @@ public static class ApplicationStore
private static readonly object _gate = new(); private static readonly object _gate = new();
/// Create a Concept for <paramref name="owner"/> — UNLESS one of this /// Create a Concept for <paramref name="owner"/> — UNLESS one of this
/// <paramref name="type"/> already exists unsubmitted. WP-35: at most one Concept per /// <paramref name="type"/> already exists unsubmitted. At most one Concept per
/// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort; /// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort;
/// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not /// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not
/// something a single Aanvraag value's own shape could ever encode, and there is no unique /// something a single Aanvraag value's own shape could ever encode, and there is no unique
@@ -121,7 +121,7 @@ public static class ApplicationStore
} }
} }
/// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of /// Cross-owner single read — the behandelaar decision endpoint's counterpart of
/// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>. /// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>.
public static Aanvraag? GetAny(string id) public static Aanvraag? GetAny(string id)
{ {
@@ -132,7 +132,7 @@ public static class ApplicationStore
} }
} }
/// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit /// Cross-owner lookup by Referentie — a real bug fix: the behandelaar besluit
/// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under /// endpoint receives the FE-facing case id from <c>IZaakSource.ListCases</c>, which under
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only /// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
/// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd /// <c>LocalZaakSource</c>'s id happens to already be the Aanvraag.Id — every besluit 404'd
@@ -147,7 +147,7 @@ public static class ApplicationStore
} }
} }
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this /// Admin: every case across all owners. The per-owner List is the norm; this
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll() public static IReadOnlyList<Aanvraag> ListAll()
{ {
@@ -163,7 +163,7 @@ public static class ApplicationStore
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the
/// domain reconstruction below is what enforces "0 &lt;= StepIndex &lt;= StepCount" /// domain reconstruction below is what enforces "0 &lt;= StepIndex &lt;= StepCount"
/// (<see cref="Aanvraag.Concept"/>'s own constructor throws on an out-of-range pair instead /// (<see cref="Aanvraag.Concept"/>'s own constructor throws on an out-of-range pair instead
/// of this silently writing one onto the row, the way the pre-WP-73 code did). /// of this silently writing one onto the row, the way the earlier code did).
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds) public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{ {
lock (_gate) lock (_gate)
@@ -207,7 +207,7 @@ public static class ApplicationStore
return true; return true;
} }
/// Admin: delete ANY case regardless of owner or submitted state (WP-36). The /// Admin: delete ANY case regardless of owner or submitted state. The
/// user-facing Delete refuses a submitted aanvraag and is owner-scoped; an admin /// user-facing Delete refuses a submitted aanvraag and is owner-scoped; an admin
/// managing the register may remove any case. Cascades to the case's documents /// managing the register may remove any case. Cascades to the case's documents
/// using its own owner. Returns false only when the id doesn't exist. /// using its own owner. Returns false only when the id doesn't exist.
@@ -231,7 +231,7 @@ public static class ApplicationStore
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling, /// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null /// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard). WP-73: the returned /// if the aanvraag is gone or already submitted (idempotency guard). The returned
/// <see cref="Aanvraag.Submitted"/> is constructed with a non-null Referentie/SubmittedAt by /// <see cref="Aanvraag.Submitted"/> is constructed with a non-null Referentie/SubmittedAt by
/// its own required members — there is no longer a null-forgiving deref anywhere down the /// its own required members — there is no longer a null-forgiving deref anywhere down the
/// line reading them back (<c>StatusAt</c>, <c>IZaakSource.CreateZaak</c>). Submitting also /// line reading them back (<c>StatusAt</c>, <c>IZaakSource.CreateZaak</c>). Submitting also
@@ -267,7 +267,7 @@ public static class ApplicationStore
} }
} }
/// <summary>Persist the zaak URL CreateZaak (WP-50) registered for this aanvraag. No-op if /// <summary>Persist the zaak URL CreateZaak registered for this aanvraag. No-op if
/// the aanvraag is gone (shouldn't happen — this runs right after Submit found it).</summary> /// the aanvraag is gone (shouldn't happen — this runs right after Submit found it).</summary>
public static void SetZaakUrl(string id, string zaakUrl) public static void SetZaakUrl(string id, string zaakUrl)
{ {
@@ -297,13 +297,13 @@ public static class ApplicationStore
public enum RecordBesluitOutcome { Ok, NotFound, Conflict } public enum RecordBesluitOutcome { Ok, NotFound, Conflict }
/// <summary>Record a behandelaar's decision (WP-65b) — cross-owner like /// <summary>Record a behandelaar's decision — cross-owner like
/// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case. /// <see cref="DeleteAny"/>, since a behandelaar decides on any citizen's case.
/// WP-68 (F2): the transition-legality check (<see cref="BeoordelingRules.CanDecide"/>) /// The transition-legality check (<see cref="BeoordelingRules.CanDecide"/>)
/// now runs INSIDE this lock, against a status read fresh under the lock, rather than in /// now runs INSIDE this lock, against a status read fresh under the lock, rather than in
/// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's /// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's
/// check before either wrote, letting the second silently overwrite a terminal decision. /// check before either wrote, letting the second silently overwrite a terminal decision.
/// WP-73: <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/> /// <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// require a non-null Toelichting by their own shape — the endpoint already 400s a missing /// require a non-null Toelichting by their own shape — the endpoint already 400s a missing
/// one (<c>BeoordelingRules.RequiresToelichting</c>), and this is the defense-in-depth /// one (<c>BeoordelingRules.RequiresToelichting</c>), and this is the defense-in-depth
/// backstop for any other caller (this method is public, and e.g. /// backstop for any other caller (this method is public, and e.g.
@@ -1,7 +1,7 @@
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// A persisted, DATA-MINIMISED authorization/PII-reveal audit entry (WP-41, PRD-0002 §8): /// A persisted, DATA-MINIMISED authorization/PII-reveal audit entry (PRD-0002 §8):
/// who (acting role, not identity), what action, on which resource ref, allow or deny, and /// who (acting role, not identity), what action, on which resource ref, allow or deny, and
/// the correlation id — **never** a name, BSN, or the value that was (or wasn't) revealed. /// the correlation id — **never** a name, BSN, or the value that was (or wasn't) revealed.
/// Id is EF Core's auto-increment key (not positional), mirroring <see cref="AuditEntry"/>. /// Id is EF Core's auto-increment key (not positional), mirroring <see cref="AuditEntry"/>.
@@ -37,7 +37,7 @@ public static class AuthzAuditStore
} }
} }
/// Newest first. Ordered client-side: SQLite can't ORDER BY a DateTimeOffset (WP-36). /// Newest first. Ordered client-side: SQLite can't ORDER BY a DateTimeOffset.
public static IReadOnlyList<AuthzAuditEntry> List() public static IReadOnlyList<AuthzAuditEntry> List()
{ {
lock (_gate) lock (_gate)
@@ -7,7 +7,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first /// The letter (brief) — one demo brief per owner, created from a template on first
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors /// read. EF Core/SQLite-backed (was a static Dictionary), mirrors
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the /// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
/// server is authoritative for transitions); the FE mirrors them in its pure reducer /// server is authoritative for transitions); the FE mirrors them in its pure reducer
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not /// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
@@ -23,11 +23,11 @@ public sealed class BriefEntity
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; } public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new(); public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft"); public BriefStatusDto Status { get; set; } = new("draft");
/// Which sub-organization's org template themes this letter (WP-23). /// Which sub-organization's org template themes this letter.
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers; public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
/// Pinned at send: sent letters are immutable, a republish never re-themes them. /// Pinned at send: sent letters are immutable, a republish never re-themes them.
public int? SentOrgTemplateVersion { get; set; } public int? SentOrgTemplateVersion { get; set; }
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint /// The composed HTML archived at send — from here on the preview endpoint
/// serves this verbatim, so a later org-template republish never re-renders it. /// serves this verbatim, so a later org-template republish never re-renders it.
public string? ArchivedHtml { get; set; } public string? ArchivedHtml { get; set; }
@@ -47,7 +47,7 @@ public static class BriefStore
private static readonly object _gate = new(); private static readonly object _gate = new();
/// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null — /// Pure query (CQ-007): no write. `GET /brief` 404s when this returns null —
/// the owner's first-ever draft is created only through the explicit `ResetAndCreate` /// the owner's first-ever draft is created only through the explicit `ResetAndCreate`
/// command (`POST /brief/reset`), never as a side effect of a read. /// command (`POST /brief/reset`), never as a side effect of a read.
public static BriefEntity? Get(string owner) public static BriefEntity? Get(string owner)
@@ -110,10 +110,10 @@ public static class BriefStore
var outcome = BriefRules.CanSend(e.Status); var outcome = BriefRules.CanSend(e.Status);
if (outcome != Outcome.Ok) return (outcome, null); if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("sent", SentAt: at); e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on // Pin the org-template version the letter was sent with: from here on
// its appearance is frozen — republishing the template touches unsent briefs only. // its appearance is frozen — republishing the template touches unsent briefs only.
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId); e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint // Archive the composed HTML at this exact instant: the preview endpoint
// serves this verbatim once sent, so a later republish never re-renders it. // serves this verbatim once sent, so a later republish never re-renders it.
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion); var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false); e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores /// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape /// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected /// predates this persistence layer, which keeps that shape — so they can't take a constructor-injected
/// DbContext; each store method opens one here, uses it, and disposes it under its /// DbContext; each store method opens one here, uses it, and disposes it under its
/// own lock instead. /// own lock instead.
/// </summary> /// </summary>
@@ -4,7 +4,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file /// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a /// purely so a re-opened wizard can preview/download what was uploaded — a
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never /// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them. /// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary> /// </summary>
@@ -14,7 +14,7 @@ public sealed record StoredDocument(
{ {
public bool Linked { get; set; } public bool Linked { get; set; }
/// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload (WP-51) /// <summary>The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload
/// registers one — null under the local source. Persisted so the later zaak-link step can /// registers one — null under the local source. Persisted so the later zaak-link step can
/// find it without re-uploading; not part of the positional constructor, same reasoning as /// find it without re-uploading; not part of the positional constructor, same reasoning as
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary> /// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
@@ -30,7 +30,7 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
} }
/// <summary> /// <summary>
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static /// EF Core/SQLite-backed document store + audit log (was a static
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only /// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// one writer at a time anyway, and this process already serialized all access /// one writer at a time anyway, and this process already serialized all access
/// through a single gate, so it now doubles as a coarse single-writer guard for /// through a single gate, so it now doubles as a coarse single-writer guard for
@@ -85,7 +85,7 @@ public static class DocumentStore
} }
} }
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already- /// <summary>Documents by DocumentId (the beoordeling detail reads an aanvraag's already-
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is /// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
/// keyed by the wizard's own LocalId instead.</summary> /// keyed by the wizard's own LocalId instead.</summary>
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds) public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
@@ -114,7 +114,7 @@ public static class DocumentStore
} }
} }
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary> /// <summary>Persist the DRC url an OpenZaak upload registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl) public static void SetDrcUrl(string documentId, string drcUrl)
{ {
lock (_gate) lock (_gate)
@@ -181,7 +181,7 @@ public static class DocumentStore
} }
/// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive /// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive
/// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass /// **already redacted** (BIO-005) — the two citizen call sites pass
/// <see cref="Pii.MaskTail"/> of the owner BSN, `delete-admin` passes the literal /// <see cref="Pii.MaskTail"/> of the owner BSN, `delete-admin` passes the literal
/// `"admin"`. The unmasked BSN lives only in <see cref="StoredDocument.Owner"/>, which is /// `"admin"`. The unmasked BSN lives only in <see cref="StoredDocument.Owner"/>, which is
/// the authorization key and stays untouched. Masking here instead would have to guess /// the authorization key and stays untouched. Masking here instead would have to guess
@@ -14,7 +14,7 @@ public sealed class FeatureFlagEntity
public sealed record ResolvedFlag(string Key, string Description, bool Enabled); public sealed record ResolvedFlag(string Key, string Description, bool Enabled);
/// <summary> /// <summary>
/// Runtime feature-flag state (WP-47). SQLite-backed like <see cref="OrgTemplateStore"/>, same /// Runtime feature-flag state. SQLite-backed like <see cref="OrgTemplateStore"/>, same
/// single-gate idiom. The CATALOG (which flags exist + their defaults) is code /// single-gate idiom. The CATALOG (which flags exist + their defaults) is code
/// (<see cref="FeatureFlags"/>); this store only holds the admin's on/off overrides. An unknown /// (<see cref="FeatureFlags"/>); this store only holds the admin's on/off overrides. An unknown
/// key is never writable/enabled — the code catalog is the authority. /// key is never writable/enabled — the code catalog is the authority.
@@ -4,10 +4,10 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The documents seam (WP-51), sibling of <see cref="IZaakSource"/>: uploads always land /// The documents seam, sibling of <see cref="IZaakSource"/>: uploads always land
/// locally first (<see cref="DocumentStore"/> stays the record of truth for preview/download/ /// locally first (<see cref="DocumentStore"/> stays the record of truth for preview/download/
/// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen, /// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen)
/// WP-50) — this interface is only the OpenZaak integration side-effect, selected the same way /// — this interface is only the OpenZaak integration side-effect, selected the same way
/// (<c>Zgw:Enabled</c>). Default binding is <see cref="LocalDocumentSource"/> (offline); /// (<c>Zgw:Enabled</c>). Default binding is <see cref="LocalDocumentSource"/> (offline);
/// <c>OpenZaakDocumentSource</c> also registers each upload as a DRC /// <c>OpenZaakDocumentSource</c> also registers each upload as a DRC
/// enkelvoudiginformatieobject and links it to a zaak once one exists. /// enkelvoudiginformatieobject and links it to a zaak once one exists.
@@ -16,17 +16,17 @@ public interface IDocumentSource
{ {
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the /// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active. /// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.
/// <paramref name="caller"/> (WP-53) is both the document's owner (<c>DocumentStore</c>'s /// <paramref name="caller"/> is both the document's owner (<c>DocumentStore</c>'s
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary> /// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload( UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, ZorgverlenerCaller caller); byte[] content, ZorgverlenerCaller caller);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag /// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag:
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak /// local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once /// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in /// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link) — minted with <paramref name="caller"/>'s /// which case there is nothing extra to link) — minted with <paramref name="caller"/>'s
/// identity (WP-53).</summary> /// identity.</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller); void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller);
} }
+14 -14
View File
@@ -5,25 +5,25 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an <see cref="Aanvraag"/> /// The cases (zaken) READ seam. A "zaak" in ZGW terms is an <see cref="Aanvraag"/>
/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak) /// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak)
/// replace the local SQLite store <em>behind the same <see cref="AanvraagSummaryDto"/> /// replace the local SQLite store <em>behind the same <see cref="AanvraagSummaryDto"/>
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
/// ///
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c> /// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 (WP-49) was read-only; <see cref="CreateZaak"/> /// swaps in <c>OpenZaakZaakSource</c>. The first slice was read-only; <see cref="CreateZaak"/>
/// (WP-50) is the first write. The interface returns the wire DTO (not the domain /// is the first write. The interface returns the wire DTO (not the domain
/// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak /// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak
/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag. /// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
/// </summary> /// </summary>
public interface IZaakSource public interface IZaakSource
{ {
/// <summary>Every case across every owner, newest-first (the admin cross-owner list, /// <summary>Every case across every owner, newest-first (the admin cross-owner list) —
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary> /// cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now); IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now);
/// <summary> /// <summary>
/// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of /// Only <paramref name="caller"/>'s own cases — the citizen-scoped counterpart of
/// <see cref="ListCases"/>, backing the citizen's own dashboard. The local source filters /// <see cref="ListCases"/>, backing the citizen's own dashboard. The local source filters
/// <c>ApplicationStore</c> by owner (unchanged behaviour); the OpenZaak source adds ZGW's /// <c>ApplicationStore</c> by owner (unchanged behaviour); the OpenZaak source adds ZGW's
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen /// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
@@ -32,9 +32,9 @@ public interface IZaakSource
IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now); IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now);
/// <summary> /// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is /// Register a just-submitted <paramref name="aanvraag"/> as a zaak. The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran, hence the /// already persisted locally (<c>ApplicationStore.Submit</c> already ran, hence the
/// <see cref="Aanvraag.Submitted"/> parameter type — WP-73: a freshly submitted aanvraag /// <see cref="Aanvraag.Submitted"/> parameter type — a freshly submitted aanvraag
/// always has a Referentie, so neither implementation needs a null-forgiving deref for it /// always has a Referentie, so neither implementation needs a null-forgiving deref for it
/// any more) — this is the integration side-effect, and (Referentie, Status) is what the /// any more) — this is the integration side-effect, and (Referentie, Status) is what the
/// submit endpoint hands back to the FE (ADR-0001: route the create through the existing /// submit endpoint hands back to the FE (ADR-0001: route the create through the existing
@@ -42,19 +42,19 @@ public interface IZaakSource
/// the already-computed local reference/status (ZaakUrl null — nothing to persist); the /// the already-computed local reference/status (ZaakUrl null — nothing to persist); the
/// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same /// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same
/// shape, returning the zaak's URL so the endpoint can persist it /// shape, returning the zaak's URL so the endpoint can persist it
/// (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it to later link documents to this /// (<see cref="ApplicationStore.SetZaakUrl"/>; linking documents to this
/// zaak). <paramref name="caller"/> (WP-53) is the acting citizen — the ZGW JWT's audit /// zaak later needs it). <paramref name="caller"/> is the acting citizen — the ZGW JWT's audit
/// claims reflect them, not a static config identity. /// claims reflect them, not a static config identity.
/// </summary> /// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller); (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller);
/// <summary> /// <summary>
/// Extend a behandelaar's already-locally-recorded decision (WP-65b's /// Extend a behandelaar's already-locally-recorded decision (
/// <c>ApplicationStore.RecordBesluit</c> already ran) with a ZGW-side status transition /// <c>ApplicationStore.RecordBesluit</c> already ran) with a ZGW-side status transition
/// (WP-66) — the write counterpart to <see cref="CreateZaak"/>'s initial status. The local /// the write counterpart to <see cref="CreateZaak"/>'s initial status. The local
/// source is a no-op (the decision IS the record of truth there, unchanged from before this /// source is a no-op (the decision IS the record of truth there, unchanged from before this
/// seam existed); the OpenZaak source POSTs a new Statussen entry to /// seam existed); the OpenZaak source POSTs a new Statussen entry to
/// <paramref name="aanvraag"/>'s zaak. <paramref name="caller"/> (WP-53/62) is the acting /// <paramref name="aanvraag"/>'s zaak. <paramref name="caller"/> is the acting
/// medewerker. /// medewerker.
/// </summary> /// </summary>
void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller); void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller);
@@ -5,7 +5,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The default <see cref="IDocumentSource"/> — uploads go only to the local SQLite /// The default <see cref="IDocumentSource"/> — uploads go only to the local SQLite
/// <see cref="DocumentStore"/>, exactly as before this seam existed (WP-51). Zero behaviour /// <see cref="DocumentStore"/>, exactly as before this seam existed. Zero behaviour
/// change: this is the same <c>DocumentStore.Add</c>/<c>DocumentStore.Link</c> the upload/ /// change: this is the same <c>DocumentStore.Add</c>/<c>DocumentStore.Link</c> the upload/
/// submit endpoints used to call inline. /// submit endpoints used to call inline.
/// </summary> /// </summary>
@@ -6,7 +6,7 @@ namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The default <see cref="IZaakSource"/> — the cases come from the local SQLite /// The default <see cref="IZaakSource"/> — the cases come from the local SQLite
/// <see cref="ApplicationStore"/>, exactly as before the seam existed (WP-49). Zero /// <see cref="ApplicationStore"/>, exactly as before the seam existed. Zero
/// behaviour change: this is the same <c>ListAll().ToAdminSummaryDto(now)</c> the /// behaviour change: this is the same <c>ListAll().ToAdminSummaryDto(now)</c> the
/// <c>/admin/cases</c> endpoint used to call inline. /// <c>/admin/cases</c> endpoint used to call inline.
/// </summary> /// </summary>
@@ -15,7 +15,7 @@ public sealed class LocalZaakSource : IZaakSource
public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /aanvragen</c> used to compute /// <summary>Citizen-scoped — exactly what <c>GET /aanvragen</c> used to compute
/// inline before it was routed through this seam.</summary> /// inline before it was routed through this seam.</summary>
public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn) ApplicationStore.List(caller.Bsn)
@@ -23,11 +23,11 @@ public sealed class LocalZaakSource : IZaakSource
.Select(a => a.ToSummaryDto(now)).ToList(); .Select(a => a.ToSummaryDto(now)).ToList();
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record /// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary> /// of truth, exactly as before this seam existed. Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie, aanvraag.ToStatusDto(now), null); (aanvraag.Referentie, aanvraag.ToStatusDto(now), null);
/// <summary>No external zaak to update — the recorded decision already IS the record of /// <summary>No external zaak to update — the recorded decision already IS the record of
/// truth locally (WP-66). Zero behaviour change.</summary> /// truth locally. Zero behaviour change.</summary>
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { } public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { }
} }
@@ -3,12 +3,12 @@ using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per /// Organization template per sub-organization (Brief v2 PRD §3): one row per
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the /// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
/// append-only list of published snapshots, `PublishedVersion` points into it. /// append-only list of published snapshots, `PublishedVersion` points into it.
/// Rollback copies an old snapshot back into the draft — it never rewrites history. /// Rollback copies an old snapshot back into the draft — it never rewrites history.
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call, /// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
/// nested DTO shapes stored as JSON text columns (WP-22 posture). /// nested DTO shapes stored as JSON text columns.
/// </summary> /// </summary>
public sealed class OrgTemplateEntity public sealed class OrgTemplateEntity
{ {
@@ -3,7 +3,7 @@ using System.Text.Json;
namespace BigRegister.Domain.Applications; namespace BigRegister.Domain.Applications;
/// <summary> /// <summary>
/// The aanvraag lifecycle as a closed union (WP-73): <see cref="Concept"/> (the pre-submission /// The aanvraag lifecycle as a closed union: <see cref="Concept"/> (the pre-submission
/// wizard draft) → <see cref="Submitted"/> (awaiting a behandelaar's decision, or already /// wizard draft) → <see cref="Submitted"/> (awaiting a behandelaar's decision, or already
/// auto-rejected at submission time — see <see cref="Submitted.Reden"/>) → <see cref="Decided"/> /// auto-rejected at submission time — see <see cref="Submitted.Reden"/>) → <see cref="Decided"/>
/// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for /// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for
@@ -31,11 +31,11 @@ public abstract record Aanvraag
public required DateTimeOffset CreatedAt { get; init; } public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; } public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under /// <summary>The OpenZaak zaak's URL, set once CreateZaak registers one — null under
/// the local source, or before a zaak has been registered at all.</summary> /// the local source, or before a zaak has been registered at all.</summary>
public string? ZaakUrl { get; init; } public string? ZaakUrl { get; init; }
/// <summary>WP-60: non-null means the ZGW side of this aanvraag's last write did not /// <summary>Non-null means the ZGW side of this aanvraag's last write did not
/// complete — see <c>Api.Data.ApplicationStore.SetZgwError</c>.</summary> /// complete — see <c>Api.Data.ApplicationStore.SetZgwError</c>.</summary>
public string? ZgwError { get; init; } public string? ZgwError { get; init; }
@@ -76,7 +76,7 @@ public abstract record Aanvraag
public string? Reden { get; init; } public string? Reden { get; init; }
} }
/// <summary>A behandelaar's decision (WP-65b/68) — closed by besluit: only /// <summary>A behandelaar's decision — closed by besluit: only
/// <see cref="Afgewezen"/>/<see cref="MeerInfoGevraagd"/> require a toelichting /// <see cref="Afgewezen"/>/<see cref="MeerInfoGevraagd"/> require a toelichting
/// (<c>BeoordelingRules.RequiresToelichting</c>'s rule, now also a type, not just an endpoint /// (<c>BeoordelingRules.RequiresToelichting</c>'s rule, now also a type, not just an endpoint
/// check) — omitting it is a compile error, not merely a 400 the type happens to also let /// check) — omitting it is a compile error, not merely a 400 the type happens to also let
@@ -1,11 +1,11 @@
namespace BigRegister.Domain.Applications; namespace BigRegister.Domain.Applications;
/// <summary> /// <summary>
/// The post-submission aanvraag status lifecycle (ADR-0002, WP-63): Ingediend → In /// The post-submission aanvraag status lifecycle (ADR-0002): Ingediend → In
/// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission, /// behandeling → (Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen. Concept (pre-submission,
/// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>, /// the wizard draft) is deliberately NOT a member here — see <see cref="AanvraagStatus.Tag"/>,
/// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth /// which is null exactly when the aanvraag hasn't been submitted yet, instead of a sixth
/// "magic string" tag with no enum member to match it (WP-68 F3). /// "magic string" tag with no enum member to match it.
/// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between /// <see cref="Ingediend"/> is reserved: no endpoint sets it yet (there is no state between
/// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union /// "just submitted" and "in behandeling" in this POC) — kept because the FE's status union
/// and $localize catalogue already declare it, and removing it would ripple into both. /// and $localize catalogue already declare it, and removing it would ripple into both.
@@ -13,13 +13,13 @@ namespace BigRegister.Domain.Applications;
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen } public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary> /// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen /// A behandelaar's recorded decision — the three actions the beoordeling screen
/// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>. /// offers, each advancing an aanvraag's <see cref="AanvraagStatus"/>.
/// </summary> /// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen } public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary> /// <summary>
/// The domain projection of an aanvraag's status at a point in time (WP-68 F3) — the type /// The domain projection of an aanvraag's status at a point in time — the type
/// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in /// <c>Aanvraag.StatusAt(now)</c> returns, replacing the logic that used to live directly in
/// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a /// <c>Contracts.Mappers.ToStatusDto</c>. Constructible only via the factories below, so a
/// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only /// caller can never build e.g. a Referentie-less Goedgekeurd. <see cref="Tag"/> is null only
@@ -24,7 +24,7 @@ public enum BriefAction { Approve, Reject, Send }
/// </summary> /// </summary>
public static class Authz public static class Authz
{ {
// WP-53: role now comes from the per-request CallerIdentity the identity middleware // Role now comes from the per-request CallerIdentity the identity middleware
// resolved (StubIdentityProvider reads the same X-Role header this used to read directly) — // resolved (StubIdentityProvider reads the same X-Role header this used to read directly) —
// one source of "who", so a real IIdentityProvider swap carries this over unchanged. // one source of "who", so a real IIdentityProvider swap carries this over unchanged.
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Caller().Role); public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Caller().Role);
@@ -49,7 +49,7 @@ public static class Authz
/// BriefStore.Review enforces before its status guard; kept separate from /// BriefStore.Review enforces before its status guard; kept separate from
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches /// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
/// today's behavior exactly. The explicit Approver condition keeps the new Admin /// today's behavior exactly. The explicit Approver condition keeps the new Admin
/// role out of the review flow (WP-23) — SoD alone would have let it through. /// role out of the review flow — SoD alone would have let it through.
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
{ {
BriefAction.Approve or BriefAction.Reject => BriefAction.Approve or BriefAction.Reject =>
@@ -58,7 +58,7 @@ public static class Authz
_ => false, _ => false,
}; };
/// Org-template management (WP-23): admin-only, resource-independent — templates /// Org-template management: admin-only, resource-independent — templates
/// have no per-resource state to weigh, so role IS the whole decision here. /// have no per-resource state to weigh, so role IS the whole decision here.
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin; public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
@@ -67,18 +67,18 @@ public static class Authz
/// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here. /// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here.
public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin; public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Case management (WP-36): admin-only, resource-independent — same shape as /// Case management: admin-only, resource-independent — same shape as
/// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases /// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases
/// list + admin delete. /// list + admin delete.
public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin; public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision. /// Feature-flag management: admin-only, resource-independent — role IS the decision.
public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin; public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin;
// --- Medewerker (backoffice) capabilities (WP-62, ADR-0002 §3) ------------------------------ // --- Medewerker (backoffice) capabilities (ADR-0002 §3) ------------------------------
/// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling, /// May this caller assess/decide an aanvraag (the behandelportal's werkvoorraad + beoordeling)?
/// WP-64/65)? Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false /// Rol-based, deliberately NOT derived from PrincipalRole — a zorgverlener is false
/// regardless of X-Role, because the capability belongs to the medewerker actor kind, not to /// regardless of X-Role, because the capability belongs to the medewerker actor kind, not to
/// the dev role stand-in. Shipped to a frontend only as a decision flag, never as a rollen /// the dev role stand-in. Shipped to a frontend only as a decision flag, never as a rollen
/// matrix (ADR-0001). /// matrix (ADR-0001).
@@ -1,8 +1,8 @@
namespace BigRegister.Domain.Authorization; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// The two actor kinds a request can come from (WP-62, ADR-0002 §3): a <see cref="ZorgverlenerCaller"/> /// The two actor kinds a request can come from (ADR-0002 §3): a <see cref="ZorgverlenerCaller"/>
/// (citizen, WP-53 — subject BSN) or a <see cref="MedewerkerCaller"/> (backoffice employee — no BSN, /// (citizen — subject BSN) or a <see cref="MedewerkerCaller"/> (backoffice employee — no BSN,
/// has rollen). Resolved once per request by <see cref="IIdentityProvider"/> and stashed on /// has rollen). Resolved once per request by <see cref="IIdentityProvider"/> and stashed on
/// <see cref="HttpContext.Items"/> by the identity-resolution middleware (<c>Program.cs</c>, right /// <see cref="HttpContext.Items"/> by the identity-resolution middleware (<c>Program.cs</c>, right
/// after the correlation-id middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> /// after the correlation-id middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c>
@@ -33,7 +33,7 @@ public sealed record MedewerkerCaller(
/// <summary>Backoffice functions a medewerker holds (ADR-0002 §4: admin/auditor/institution-rep /// <summary>Backoffice functions a medewerker holds (ADR-0002 §4: admin/auditor/institution-rep
/// slot in here as extra rollen, never as new CallerIdentity variants). Deliberately one member — /// slot in here as extra rollen, never as new CallerIdentity variants). Deliberately one member —
/// WP-65 adds the next one when a capability actually needs it.</summary> /// A later capability adds the next one when it actually needs it.</summary>
public enum MedewerkerRol { Behandelaar } public enum MedewerkerRol { Behandelaar }
public static class CallerIdentityHttpContextExtensions public static class CallerIdentityHttpContextExtensions
@@ -51,10 +51,10 @@ public static class CallerIdentityHttpContextExtensions
: throw new InvalidOperationException( : throw new InvalidOperationException(
"No CallerIdentity resolved for this request — the identity middleware didn't run."); "No CallerIdentity resolved for this request — the identity middleware didn't run.");
/// <summary>The citizen-scoped narrowing (WP-62): every SSP endpoint that scopes data by owner /// <summary>The citizen-scoped narrowing: every SSP endpoint that scopes data by owner
/// needs a BSN, which only a zorgverlener has. Throws rather than silently degrading — no /// needs a BSN, which only a zorgverlener has. Throws rather than silently degrading — no
/// medewerker reaches these endpoints today (the behandelportal calls its own endpoints, /// medewerker reaches these endpoints today (the behandelportal calls its own endpoints),
/// WP-64+), so this is a loud "wrong actor kind" bug detector, not a user-facing path.</summary> /// so this is a loud "wrong actor kind" bug detector, not a user-facing path.</summary>
public static ZorgverlenerCaller Zorgverlener(this HttpContext ctx) => public static ZorgverlenerCaller Zorgverlener(this HttpContext ctx) =>
ctx.Caller() as ZorgverlenerCaller ctx.Caller() as ZorgverlenerCaller
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@@ -1,11 +1,11 @@
namespace BigRegister.Domain.Authorization; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — one of the two actor /// Resolves the acting <see cref="CallerIdentity"/> for a request — one of the two actor
/// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker /// kinds (ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker
/// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is /// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is
/// the only implementation today, and is registered only in Development (<c>Program.cs</c>, /// the only implementation today, and is registered only in Development (<c>Program.cs</c>,
/// RB-09/BIO-002). /// BIO-002).
/// </summary> /// </summary>
public interface IIdentityProvider public interface IIdentityProvider
{ {
@@ -3,18 +3,18 @@ using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization; namespace BigRegister.Domain.Authorization;
/// <summary> /// <summary>
/// Dev stub (WP-53, extended WP-62) — NOT a security boundary, same caveat as /// Dev stub — NOT a security boundary, same caveat as
/// <see cref="Authz.ResolvePrincipal"/> (which this provider now backs). Role comes from the /// <see cref="Authz.ResolvePrincipal"/> (which this provider now backs). Role comes from the
/// existing client-asserted X-Role header (mirrors the FE's <c>?role=</c> toggle) and applies to /// existing client-asserted X-Role header (mirrors the FE's <c>?role=</c> toggle) and applies to
/// either actor kind. Presence of X-Medewerker selects a <see cref="MedewerkerCaller"/> (id + /// either actor kind. Presence of X-Medewerker selects a <see cref="MedewerkerCaller"/> (id +
/// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today — /// rollen from X-Rollen) and takes precedence over X-Subject; absent — every request today —
/// falls through to the WP-53 <see cref="ZorgverlenerCaller"/> path unchanged: subject BSN from /// falls through to the <see cref="ZorgverlenerCaller"/> path unchanged: subject BSN from
/// X-Subject, defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>). /// X-Subject, defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>).
/// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims /// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims
/// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that /// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that
/// swap happens. /// swap happens.
/// ///
/// Registered only in Development (<c>Program.cs</c>, RB-09/BIO-002) — it always invents a /// Registered only in Development (<c>Program.cs</c>, BIO-002) — it always invents a
/// caller for a request with no credential, which is a deliberate developer convenience, not /// caller for a request with no credential, which is a deliberate developer convenience, not
/// something a production build may do. Its own return type stays non-nullable: unlike /// something a production build may do. Its own return type stays non-nullable: unlike
/// <see cref="IIdentityProvider.Resolve"/>, this stub never has "no identity" to report. /// <see cref="IIdentityProvider.Resolve"/>, this stub never has "no identity" to report.
@@ -3,10 +3,10 @@ using BigRegister.Domain.Applications;
namespace BigRegister.Domain.Beoordeling; namespace BigRegister.Domain.Beoordeling;
/// <summary> /// <summary>
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Used from /// SERVER-OWNED rules for the behandelportal's case-treatment decision. Used from
/// both the beoordeling read side (<see cref="CanDecide"/> backs the `canBesluiten` decision /// both the beoordeling read side (<see cref="CanDecide"/> backs the `canBesluiten` decision
/// flag) and the besluit write side (the SAME `CanDecide` gates the mutation, and — since /// flag) and the besluit write side (the SAME `CanDecide` gates the mutation, and
/// WP-68 F2 — runs inside the write lock, so the two can never drift and a concurrent besluit /// runs inside the write lock, so the two can never drift and a concurrent besluit
/// can't race past the check). /// can't race past the check).
/// </summary> /// </summary>
public static class BeoordelingRules public static class BeoordelingRules
@@ -18,7 +18,7 @@ public static class BeoordelingRules
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
or AanvraagStatusTag.MeerInfoGevraagd; or AanvraagStatusTag.MeerInfoGevraagd;
/// <summary>WP-68 F6: moved here from an inline check in the besluit endpoint. The /// <summary>Moved here from an inline check in the besluit endpoint. The
/// toelichting (behandelaar's explanation) is required for every besluit except an /// toelichting (behandelaar's explanation) is required for every besluit except an
/// approval — Afwijzen/MeerInfoOpvragen must justify why (becomes the published status's /// approval — Afwijzen/MeerInfoOpvragen must justify why (becomes the published status's
/// Reden).</summary> /// Reden).</summary>
@@ -43,7 +43,7 @@ public static class DocumentRules
new DocumentCategory("nascholing", "Nascholingscertificaten", new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true), "Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
}, },
// WP-23: the admin's org-template logo rides the same upload machinery as the // The admin's org-template logo rides the same upload machinery as the
// wizard documents — one category under its own "wizard" id. // wizard documents — one category under its own "wizard" id.
"org-template" => new[] "org-template" => new[]
{ {
@@ -5,7 +5,7 @@ namespace BigRegister.Domain.Intake;
/// scholing question is required. The frontend receives this value /// scholing question is required. The frontend receives this value
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback /// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the /// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
/// backend re-validating it as the authority on submit (WP-69) /// backend re-validating it as the authority on submit —
/// <c>POST /aanvragen/{id}/submit</c> (intake-typed aanvragen only) calls it before /// <c>POST /aanvragen/{id}/submit</c> (intake-typed aanvragen only) calls it before
/// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts /// writing anything, and a violation 400s (<c>ProblemDetails</c>), never silently accepts
/// an incomplete answer. /// an incomplete answer.
@@ -15,10 +15,10 @@ public static class IntakePolicy
public const int ScholingThreshold = 1000; public const int ScholingThreshold = 1000;
/// <summary> /// <summary>
/// Completeness rule for the scholing question (WP-69) — not merit: below /// Completeness rule for the scholing question — not merit: below
/// <see cref="ScholingThreshold"/> an answer must be present, but "nee" is a legal answer /// <see cref="ScholingThreshold"/> an answer must be present, but "nee" is a legal answer
/// that still submits (turning "few uren + no scholing" into a rejection is out of scope, /// that still submits (turning "few uren + no scholing" into a rejection is out of scope).
/// see the WP). Three-valued, so two parameters (uren, punten) couldn't express it: /// Three-valued, so two parameters (uren, punten) couldn't express it:
/// <list type="bullet"> /// <list type="bullet">
/// <item>below threshold and no answer at all ⇒ incomplete;</item> /// <item>below threshold and no answer at all ⇒ incomplete;</item>
/// <item>answered <c>true</c> (scholing gevolgd) ⇒ punten required and non-negative /// <item>answered <c>true</c> (scholing gevolgd) ⇒ punten required and non-negative
@@ -5,7 +5,7 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Domain.Letters; namespace BigRegister.Domain.Letters;
/// <summary> /// <summary>
/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each /// SERVER-OWNED brief state-transition and authorization rules (TE-008). Each
/// method is a pure decision over (status tag, actor role, entity completeness) — /// method is a pure decision over (status tag, actor role, entity completeness) —
/// extracted out of <see cref="BriefStore"/>'s lock-held, DB-opening methods so the /// extracted out of <see cref="BriefStore"/>'s lock-held, DB-opening methods so the
/// decision can be unit-tested without a booted host or a real SQLite file. Callers /// decision can be unit-tested without a booted host or a real SQLite file. Callers
@@ -6,7 +6,7 @@ using BigRegister.Api.Data;
namespace BigRegister.Domain.Letters; namespace BigRegister.Domain.Letters;
/// <summary> /// <summary>
/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact. /// Server-rendered letter HTML — the archived, "what is sent" artifact.
/// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>, /// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>,
/// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift). /// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift).
/// ///
@@ -151,7 +151,8 @@ public static class LetterHtml
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>"); private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
// Walks up from the running assembly's own directory (NOT the process cwd, which // Walks up from the running assembly's own directory (NOT the process cwd, which
// varies by how `dotnet run`/docker/tests invoke it — see docs/project/backlog/WP-25) until // varies by how `dotnet run`/docker/tests invoke it — see the letter-preview-html design
// note in docs/project/backlog/) until
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the // it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
// api container's `/src` for exactly this walk to resolve there too. // api container's `/src` for exactly this walk to resolve there too.
private static string FindLetterCss() private static string FindLetterCss()
@@ -9,8 +9,8 @@ public enum StatusTag
} }
/// <summary> /// <summary>
/// Status as a closed union: each variant carries exactly the data that makes sense for it /// Status as a closed union: each variant carries exactly the data that makes sense for it.
/// (WP-73). Only <see cref="Geregistreerd"/> carries a herregistratie deadline; only /// Only <see cref="Geregistreerd"/> carries a herregistratie deadline; only
/// <see cref="Geschorst"/> and <see cref="Doorgehaald"/> carry a reden — and there it is /// <see cref="Geschorst"/> and <see cref="Doorgehaald"/> carry a reden — and there it is
/// required, not nullable (the old flat record left <c>Reden</c> nullable on every tag, /// required, not nullable (the old flat record left <c>Reden</c> nullable on every tag,
/// diverging from the frontend union, which has always required it on those two variants — /// diverging from the frontend union, which has always required it on those two variants —
@@ -22,9 +22,9 @@ public static class SubmissionRules
// RULE: a contact change needs a well-formed Dutch phone number (10 digits, leading // RULE: a contact change needs a well-formed Dutch phone number (10 digits, leading
// 0, formatting stripped). The BRP address is authoritative and cannot be changed // 0, formatting stripped). The BRP address is authoritative and cannot be changed
// here (WP-34), so only the phone is submitted. The server re-validates format // here, so only the phone is submitted. The server re-validates format
// authoritatively (the FE check is UX-only) — and must strip the SAME formatting the // authoritatively (the FE check is UX-only) — and must strip the SAME formatting the
// FE's parseTelefoonnummer does (whitespace/dashes/parens, a leading +31 → 0; WP-75), // FE's parseTelefoonnummer does (whitespace/dashes/parens, a leading +31 → 0),
// or the two sides disagree on what's a valid number. // or the two sides disagree on what's a valid number.
public static string? RejectPhoneChange(string telefoon) public static string? RejectPhoneChange(string telefoon)
{ {
+66 -66
View File
@@ -39,20 +39,20 @@ const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static // The three stores (Applications/Documents/Briefs — Data/*.cs) are static
// classes that open their own short-lived AppDbContext per call (see Db.Create), // classes that open their own short-lived AppDbContext per call (see Db.Create),
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring // not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
// the connection string still goes through IConfiguration so tests/deployments can // the connection string still goes through IConfiguration so tests/deployments can
// override it (ConnectionStrings:AppDb) without touching this file. // override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
// WP-53 (extended WP-62): the per-request acting caller — resolved once (middleware, below) // The per-request acting caller — resolved once (middleware, below)
// into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and // into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/ // every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real // X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
// DigiD/employee-SSO provider swaps in without touching a consumer. // DigiD/employee-SSO provider swaps in without touching a consumer.
// //
// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no // BIO-002: StubIdentityProvider invents a citizen identity for any request with no
// credential at all — a production behandelportal build sends no X-Medewerker header, so it // credential at all — a production behandelportal build sends no X-Medewerker header, so it
// used to authenticate every request as the seeded citizen (open on that citizen's own rights, // used to authenticate every request as the seeded citizen (open on that citizen's own rights,
// including CanRevealBigNummer). Registering the stub only in Development, and failing to // including CanRevealBigNummer). Registering the stub only in Development, and failing to
@@ -65,10 +65,10 @@ if (builder.Environment.IsDevelopment())
else if (builder.Environment.IsProduction()) else if (builder.Environment.IsProduction())
throw new InvalidOperationException( throw new InvalidOperationException(
"No IIdentityProvider is registered for a Production environment. StubIdentityProvider " + "No IIdentityProvider is registered for a Production environment. StubIdentityProvider " +
"is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " + "is Development-only (BIO-002); there is no real DigiD/employee-SSO provider in " +
"this POC yet. Register one before deploying to Production."); "this POC yet. Register one before deploying to Production.");
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // The cases (zaken) READ path goes through IZaakSource so a real ZGW backend
// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never // (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never
// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the // changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the
// OpenZaak client (needs the base URLs + credentials in the Zgw config section). // OpenZaak client (needs the base URLs + credentials in the Zgw config section).
@@ -77,11 +77,11 @@ if (zgw.Enabled)
{ {
builder.Services.AddSingleton(zgw); builder.Services.AddSingleton(zgw);
builder.Services.AddSingleton<ZgwTokenProvider>(); builder.Services.AddSingleton<ZgwTokenProvider>();
// WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the // A bounded client timeout matters once ZgwHttpClient retries — without one, the
// sources' sync-over-async call (no CancellationToken threaded through) could block a // sources' sync-over-async call (no CancellationToken threaded through) could block a
// thread-pool thread for HttpClient's 100s default times 3 attempts. // thread-pool thread for HttpClient's 100s default times 3 attempts.
var zaakClientBuilder = builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15)); var zaakClientBuilder = builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above. // The documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
var documentClientBuilder = builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15)); var documentClientBuilder = builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
// Opt-in diagnostic for the still-unexplained per-container flake (see // Opt-in diagnostic for the still-unexplained per-container flake (see
@@ -101,10 +101,10 @@ else
var app = builder.Build(); var app = builder.Build();
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only // Migrate on every startup, seed nothing: unlike SeedData's read-only
// reference fixtures (registration/diplomas/notes — untouched by this WP, still // reference fixtures (registration/diplomas/notes — untouched by this change, still
// static in-memory), Applications/Documents/Briefs never had seed data — they // static in-memory), Applications/Documents/Briefs never had seed data — they
// started empty and accumulated through normal use before this WP too. A fresh // started empty and accumulated through normal use before this change too. A fresh
// SQLite file just starts empty again, same as the old in-memory dictionaries did. // SQLite file just starts empty again, same as the old in-memory dictionaries did.
using (var db = Db.Create()) using (var db = Db.Create())
db.Database.Migrate(); db.Database.Migrate();
@@ -124,9 +124,9 @@ app.Use(async (ctx, next) =>
await next(ctx); await next(ctx);
}); });
// WP-53: resolve the acting citizen once per request, right after correlation — everything // Resolve the acting citizen once per request, right after correlation — everything
// downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of // downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of
// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded // re-deriving "who" itself. BIO-002: a null resolution is "no identity", not "the seeded
// citizen" — this is the one place that turns it into a response (401) rather than letting it // citizen" — this is the one place that turns it into a response (401) rather than letting it
// flow downstream as a silent identity substitution. // flow downstream as a silent identity substitution.
var identityProvider = app.Services.GetRequiredService<IIdentityProvider>(); var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
@@ -142,7 +142,7 @@ app.Use(async (ctx, next) =>
await next(ctx); await next(ctx);
}); });
// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to // BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it // gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it
// out") let a caller fire requests straight from the browser. Development-only, like the // out") let a caller fire requests straight from the browser. Development-only, like the
// dev-role/scenario-toggle hatches this POC already keeps out of production builds // dev-role/scenario-toggle hatches this POC already keeps out of production builds
@@ -150,8 +150,8 @@ app.Use(async (ctx, next) =>
// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI // Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI
// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it // resolves ISwaggerProvider straight out of the DI container to build swagger.json — it
// never sends an HTTP request through this pipeline, so it never touches this middleware at // never sends an HTTP request through this pipeline, so it never touches this middleware at
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's // all, gated or not. Verified empirically (see rb-15.md) rather than assumed — a past
// note that this exact file has already broken that tool once. // regression already broke that tool once in this exact file.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -213,7 +213,7 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
var t = StamdataCatalog.Find(table); var t = StamdataCatalog.Find(table);
if (t is null) return Results.NotFound(); if (t is null) return Results.NotFound();
DateOnly? peildatumWaarde = null; DateOnly? peildatumWaarde = null;
// RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as // BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as
// an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an // an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an
// admin-gated but still user-supplied string needs the same 400 path every other bad-input // admin-gated but still user-supplied string needs the same 400 path every other bad-input
// check in this file uses, not a crash. // check in this file uses, not a crash.
@@ -250,7 +250,7 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline // Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download). // for pdf/image (browser renders it), attachment otherwise (download).
// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a // Scoped like DELETE on the same resource (BIO-004): the owning citizen, or a
// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than // behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than
// 403s, so the endpoint never confirms that a document id exists. // 403s, so the endpoint never confirms that a document id exists.
api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) => api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) =>
@@ -272,7 +272,7 @@ api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx)
api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) => api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
{ {
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the // Owner-scoped (BIO-004): someone else's localId reads back as "unknown", the
// same answer an id that never existed gets. // same answer an id that never existed gets.
var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId); var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d) var results = ids.Select(id => found.TryGetValue(id, out var d)
@@ -301,7 +301,7 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await file.CopyToAsync(ms); await file.CopyToAsync(ms);
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add // Route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener()); var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Zorgverlener());
@@ -325,8 +325,8 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated // Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated
// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use // by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use
// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and // (BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). // unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free.
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
.Gate("CasesAdmin") .Gate("CasesAdmin")
@@ -338,7 +338,7 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
// --- reads --- // --- reads ---
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling // Routed through IZaakSource (like /admin/cases already was) rather than calling
// ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
// openzaak-integration.md's ACL caveat used to flag for this endpoint. // openzaak-integration.md's ACL caveat used to flag for this endpoint.
@@ -356,7 +356,7 @@ api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) =>
api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) => api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) =>
{ {
// Feature flag (WP-47): self-service registration can be closed by an admin. // Feature flag: self-service registration can be closed by an admin.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden); return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden);
var a = ApplicationStore.CreateConcept(req.Type, ctx.Zorgverlener().Bsn); var a = ApplicationStore.CreateConcept(req.Type, ctx.Zorgverlener().Bsn);
@@ -417,7 +417,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
}; };
// WP-69: intake-only (herregistratie has no scholing question) — guarded by `reject is // Intake-only (herregistratie has no scholing question) — guarded by `reject is
// null` so a { uren: 0 } submission is still decided on merit (RejectZeroUren) and // null` so a { uren: 0 } submission is still decided on merit (RejectZeroUren) and
// completeness is moot; placed before the document-ownership check and // completeness is moot; placed before the document-ownership check and
// ApplicationStore.Submit so a rejected submit leaves the aanvraag a Concept (retryable). // ApplicationStore.Submit so a rejected submit leaves the aanvraag a Concept (retryable).
@@ -442,13 +442,13 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
// WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough // Route the create through the IZaakSource seam — LocalZaakSource is a passthrough
// of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005: // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
// zero FE contract change either way). WP-53: the caller is threaded through so the minted // zero FE contract change either way). The caller is threaded through so the minted
// ZGW JWT's user_id/user_representation reflect the acting citizen, not a static config value. // ZGW JWT's user_id/user_representation reflect the acting citizen, not a static config value.
// //
// WP-60: the local submit above already committed — it is never rolled back on a ZGW // The local submit above already committed — it is never rolled back on a ZGW
// failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged // failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged
// one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught // one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught
// separately so a create-zaak failure doesn't also skip the (still-local) document link. // separately so a create-zaak failure doesn't also skip the (still-local) document link.
@@ -465,7 +465,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
RecordZgwDivergence(ctx, id, referentie, ex); RecordZgwDivergence(ctx, id, referentie, ex);
} }
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the // Link the submitted documents to the zaak — LocalDocumentSource is exactly the
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally // DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
// POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists. // POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists.
if (documentIds is not null) if (documentIds is not null)
@@ -487,7 +487,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- // --- Admin cases: cross-owner list + admin delete, gated by `cases:manage`. ---
// --- reads --- // --- reads ---
@@ -497,7 +497,7 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
.Produces<List<AanvraagSummaryDto>>() .Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated // Queryable authz/PII-reveal audit trail — data-minimised, no PII. Admin-gated
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. // via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
Results.Ok(AuthzAuditStore.List() Results.Ok(AuthzAuditStore.List()
@@ -522,9 +522,9 @@ api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ct
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. --- // --- Werkvoorraad: the behandelportal's queue of aanvragen needing treatment. ---
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`)
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags — // rather than the admin role, and pre-filtered to the two "still open" status tags —
// a behandelaar never needs to see a Concept (not their business yet) or a terminal case. // a behandelaar never needs to see a Concept (not their business yet) or a terminal case.
api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () => api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
@@ -534,9 +534,9 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
.Produces<List<AanvraagSummaryDto>>() .Produces<List<AanvraagSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording // --- Beoordeling: one aanvraag's case-treatment detail — read side only (recording
// a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method: // a decision is the second half). Reads through IZaakSource.ListCases (no new seam method:
// adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n) // adding one now would force an OpenZaak get-by-id + mapper, which is a later change's surface) — O(n)
// over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here // over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here
// same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept). // same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept).
api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) => api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
@@ -546,11 +546,11 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
if (c is null || c.Status.Tag == "Concept") return Results.NotFound(); if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
var docs = DocumentStore.ByIds(c.DocumentIds) var docs = DocumentStore.ByIds(c.DocumentIds)
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
// Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and // Belt and braces: ToAdminSummaryDto already masks the local source and
// MaskTail is idempotent, but IZaakSource has a second implementation whose Owner // MaskTail is idempotent, but IZaakSource has a second implementation whose Owner
// is mapped from OpenZaak, so this stays as the guarantee for this response. // is mapped from OpenZaak, so this stays as the guarantee for this response.
var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) }; var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) };
// WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an // Non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
// unrecognised tag degrades to "cannot decide" instead of a 500. // unrecognised tag degrades to "cannot decide" instead of a 500.
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag); var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
var decisions = new BeoordelingDecisionsDto(canBesluiten); var decisions = new BeoordelingDecisionsDto(canBesluiten);
@@ -561,13 +561,13 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status // --- Besluit: record a behandelaar's decision, advancing the status
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource // lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
// seam) — same reasoning as the GET above. The transition-legality check // seam) — same reasoning as the GET above. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses, // (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
// so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's // so the two can never drift — and it now runs inside ApplicationStore.RecordBesluit's
// write lock rather than here, so two concurrent besluiten can't both pass it before either // write lock rather than here, so two concurrent besluiten can't both pass it before either
// writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to // writes. Once the local decision has committed, IZaakSource also gets a chance to
// advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new // advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new
// Statussen entry (see its RecordBesluit). // Statussen entry (see its RecordBesluit).
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) => api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
@@ -575,12 +575,12 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
{ {
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit)) if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest); return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
// WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable. // Moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable.
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting)) if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting))
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest); return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
// Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under // Real bug fix: `id` is the FE-facing case id from IZaakSource.ListCases — under
// OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a // OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a
// ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above // ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above
// uses), so resolve the case first and go to the local Aanvraag via its Referentie // uses), so resolve the case first and go to the local Aanvraag via its Referentie
@@ -597,12 +597,12 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
statusCode: StatusCodes.Status409Conflict); statusCode: StatusCodes.Status409Conflict);
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
// RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this // BIO-007: the gate above records that a behandelaar was allowed to act; this
// records what they decided. Without it /beheer/audit cannot answer "who rejected this // records what they decided. Without it /beheer/audit cannot answer "who rejected this
// aanvraag", which is the question the trail exists for. // aanvraag", which is the question the trail exists for.
AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx)); AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx));
// WP-60: the local decision above already committed — a ZGW failure here is caught and // The local decision above already committed — a ZGW failure here is caught and
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak // flagged rather than allowed to diverge silently, same handling as submit's create-zaak
// and document-link writes. // and document-link writes.
try try
@@ -611,7 +611,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
} }
catch (Exception ex) catch (Exception ex)
{ {
// WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed. // Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed.
RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex); RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex);
} }
@@ -625,7 +625,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is // OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it). // provisioned (out-of-band — see openzaak-integration.md, no app code subscribes it).
// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly // The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly
// rather than the Principal-shaped AuditAuthz helper below. A plain shared secret (not a // rather than the Principal-shaped AuditAuthz helper below. A plain shared secret (not a
// JWT — that's only for this BFF's OUTBOUND ZGW calls) compared in fixed time; an unconfigured // JWT — that's only for this BFF's OUTBOUND ZGW calls) compared in fixed time; an unconfigured
@@ -656,7 +656,7 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that). // tied to a specific brief's live status — see BriefDecisionsDto for that).
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like // `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
// the rest of RoleCapabilities — appended here rather than folded into that switch, since it // the rest of RoleCapabilities — appended here rather than folded into that switch, since it
// depends on CallerIdentity (medewerker rollen), not the dev X-Role stand-in. // depends on CallerIdentity (medewerker rollen), not the dev X-Role stand-in.
api.MapGet("/me", (HttpContext ctx) => api.MapGet("/me", (HttpContext ctx) =>
@@ -667,7 +667,7 @@ api.MapGet("/me", (HttpContext ctx) =>
}) })
.Produces<MeDto>(); .Produces<MeDto>();
// Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is // Feature flags. GET is readable by any principal (it drives FE gating); the toggle is
// admin-only. Catalog is code; state is the runtime override in SQLite. // admin-only. Catalog is code; state is the runtime override in SQLite.
api.MapGet("/flags", () => api.MapGet("/flags", () =>
Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList())) Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList()))
@@ -690,7 +690,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) => api.MapGet("/brief", (HttpContext ctx) =>
{ {
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first // CQ-007: a read that used to allocate a row on first call. The owner's first
// draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate) // draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
// — this GET is a pure query and 404s when there is nothing to read yet. // — this GET is a pure query and 404s when there is nothing to read yet.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn); var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
@@ -700,13 +700,13 @@ api.MapGet("/brief", (HttpContext ctx) =>
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the // Server-rendered HTML preview: "what you compose is what is sent" — the
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → // same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent // blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
// letters serve their frozen archive; anything else renders live with a watermark. // letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) => api.MapGet("/brief/preview", (HttpContext ctx) =>
{ {
// RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET // BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
// must not create a brief as a side effect either, so it 404s under the same // must not create a brief as a side effect either, so it 404s under the same
// precondition as GET /brief — in the running app the FE only reaches this endpoint // precondition as GET /brief — in the running app the FE only reaches this endpoint
// from the brief page, which has already loaded (and, if needed, reset) a brief. // from the brief page, which has already loaded (and, if needed, reset) a brief.
@@ -785,7 +785,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
var canReveal = Authz.CanRevealBigNummer(principal); var canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp; var allowed = canReveal && steppedUp;
// RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated // BIO-008: the resource ref is the brief, not the subject — a BSN concatenated
// here lands in a persisted, admin-visible column the "no PII" guarantee covers. One // here lands in a persisted, admin-visible column the "no PII" guarantee covers. One
// brief exists per owner, so the id added nothing the acting principal did not imply. // brief exists per owner, so the id added nothing the acting principal did not imply.
AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal); AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal);
@@ -810,7 +810,7 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
.WithName("briefReset") .WithName("briefReset")
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
// --- Organization templates (WP-23): the second template axis — appearance and // --- Organization templates: the second template axis — appearance and
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam // identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
// as drafter/approver); the same Authz check gates every endpoint and feeds the // as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. --- // `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
@@ -886,7 +886,7 @@ app.Run();
// One gate for every org-template endpoint — the enforce twin of the // One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
// //
// RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing // BIO-007: every gate below audits the real decision, allow *and* deny. Auditing
// only denials left /beheer/audit able to answer "who was turned away" but not "who // only denials left /beheer/audit able to answer "who was turned away" but not "who
// changed this", which for a register whose integrity is the product is the wrong half // changed this", which for a register whose integrity is the product is the wrong half
// (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate, // (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate,
@@ -914,7 +914,7 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
} }
// One gate for every admin-cases endpoint — the enforce twin of the `cases:manage` // One gate for every admin-cases endpoint — the enforce twin of the `cases:manage`
// capability RoleCapabilities emits (single Authz source, WP-36). A denial is audited. // capability RoleCapabilities emits (single Authz source). A denial is audited.
IResult CasesAdmin(HttpContext ctx, Func<IResult> action) IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
{ {
var principal = Authz.ResolvePrincipal(ctx); var principal = Authz.ResolvePrincipal(ctx);
@@ -925,8 +925,8 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
// One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) — // One gate for every behandelaar endpoint (werkvoorraad; beoordeling detail) —
// the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this // the enforce twin of `CanBeoordelen`. Unlike the other *Admin gates above, this
// checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a // checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. // zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action) IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
@@ -938,7 +938,7 @@ IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a // One gate for the feature-flag toggle — the enforce twin of `flags:manage`. Takes a
// per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of // per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of
// its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which, // its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which,
// and this is the surface CQ-004/ADR-C-009 hinge on. // and this is the surface CQ-004/ADR-C-009 hinge on.
@@ -963,11 +963,11 @@ void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, P
app.Logger.LogInformation( app.Logger.LogInformation(
"authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}", "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}",
action, resource, allowed ? "allow" : "deny", principal.Role, cid); action, resource, allowed ? "allow" : "deny", principal.Role, cid);
// Persist the queryable, data-minimised trail (WP-41) alongside the log line. // Persist the queryable, data-minimised trail alongside the log line.
AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid); AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid);
} }
// WP-60: the local write already committed — this records that its ZGW counterpart didn't, // The local write already committed — this records that its ZGW counterpart didn't,
// rather than letting the two sides diverge silently (openzaak-integration.md's "Write // rather than letting the two sides diverge silently (openzaak-integration.md's "Write
// resilience" section). Same audit trail AuditAuthz writes to (/beheer/audit), so a // resilience" section). Same audit trail AuditAuthz writes to (/beheer/audit), so a
// divergence is visible next to every other decision, not a separate mechanism. // divergence is visible next to every other decision, not a separate mechanism.
@@ -986,7 +986,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
BriefSeed.PassagesFor(e.Beroep), BriefSeed.PassagesFor(e.Beroep),
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId), Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
// Sent letters render with the version pinned at send; everything else follows // Sent letters render with the version pinned at send; everything else follows
// the sub-org's current published template (WP-23 immutability invariant). // the sub-org's current published template (immutability invariant).
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null), OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null),
// The case this letter is about — joined from the seeded zorgverlener so the // The case this letter is about — joined from the seeded zorgverlener so the
// behandel scherm can show whom/what it concerns without brief/ importing registratie. // behandel scherm can show whom/what it concerns without brief/ importing registratie.
@@ -1003,9 +1003,9 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict), _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
}; };
// RB-07/BIO-007: every brief transition already funnelled through here for its log line, // BIO-007: every brief transition already funnelled through here for its log line,
// so the audit row goes here too — a fifth transition cannot be added that logs but leaves // so the audit row goes here too — a fifth transition cannot be added that logs but leaves
// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is // no trail. Resource is the bare "brief" (never the owner's BSN); the decision is
// the transition's own outcome, so a 403 or a 409 is as visible as a success. // the transition's own outcome, so a 403 or a 409 is as visible as a success.
void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r)
{ {
@@ -1019,7 +1019,7 @@ void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, Brief
// real system ships this to structured logging / an audit store). A repeated // real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore // Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference. The key is // — so a retried submit dedupes instead of minting a second reference. The key is
// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same // scoped to the caller (BIO-018): two callers who happen to send the same
// client-chosen header value do not share a cached result. // client-chosen header value do not share a cached result.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null) IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{ {
@@ -1062,7 +1062,7 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
return result; return result;
} }
// RB-12/BIO-016: a machine-checkable "this endpoint passes through one of the five admin // BIO-016: a machine-checkable "this endpoint passes through one of the five admin
// authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting // authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting
// over the compiled lambda at test time cannot see which local function a closure calls, but // over the compiled lambda at test time cannot see which local function a closure calls, but
// endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes // endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// The Notificaties API (NRC) webhook body (WP-52) — the standard ZGW notification shape POSTed /// The Notificaties API (NRC) webhook body — the standard ZGW notification shape POSTed
/// to a subscribed <c>abonnement</c>'s <c>callbackUrl</c> on every zaak event. Only /// to a subscribed <c>abonnement</c>'s <c>callbackUrl</c> on every zaak event. Only
/// <see cref="HoofdObject"/> (the zaak's URL — not PII) is read today, for the audit trail; the /// <see cref="HoofdObject"/> (the zaak's URL — not PII) is read today, for the audit trail; the
/// rest is parsed because it's the real payload shape a live OpenZaak actually sends, not /// rest is parsed because it's the real payload shape a live OpenZaak actually sends, not
@@ -9,10 +9,10 @@ using Microsoft.Extensions.Logging;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// The <see cref="IDocumentSource"/> backed by a real OpenZaak / ZGW Documenten API (DRC, /// The <see cref="IDocumentSource"/> backed by a real OpenZaak / ZGW Documenten API (DRC).
/// WP-51). An upload always lands locally first (<see cref="DocumentStore"/> stays the record /// An upload always lands locally first (<see cref="DocumentStore"/> stays the record
/// of truth for preview/download/audit, same reasoning as <see cref="OpenZaakZaakSource"/>'s /// of truth for preview/download/audit, same reasoning as <see cref="OpenZaakZaakSource"/>'s
/// dual-write for aanvragen, WP-50) and is then ALSO registered as a DRC /// dual-write for aanvragen) and is then ALSO registered as a DRC
/// enkelvoudiginformatieobject, whose url is persisted (<see cref="DocumentStore.SetDrcUrl"/>) /// enkelvoudiginformatieobject, whose url is persisted (<see cref="DocumentStore.SetDrcUrl"/>)
/// so <see cref="LinkToZaak"/> can find it later without a re-upload. Selected only when /// so <see cref="LinkToZaak"/> can find it later without a re-upload. Selected only when
/// <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalDocumentSource"/>. /// <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalDocumentSource"/>.
@@ -27,7 +27,7 @@ public sealed class OpenZaakDocumentSource(
{ {
private readonly ZgwHttpClient zgw = new(http, tokens); private readonly ZgwHttpClient zgw = new(http, tokens);
// WP-59: per-document-type confidentiality (stamdata, ADR-0004) — "openbaar" if the // Per-document-type confidentiality (stamdata, ADR-0004) — "openbaar" if the
// category isn't in the table, so an unconfigured category never fails the upload. // category isn't in the table, so an unconfigured category never fails the upload.
private static readonly IReadOnlyDictionary<string, string> ConfidentialiteitByCategory = private static readonly IReadOnlyDictionary<string, string> ConfidentialiteitByCategory =
StamdataFile.Load<DocumentConfidentialiteit>("documentconfidentialiteit") StamdataFile.Load<DocumentConfidentialiteit>("documentconfidentialiteit")
@@ -44,7 +44,7 @@ public sealed class OpenZaakDocumentSource(
UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller) UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
// WP-60: once DocumentStore.Add (below) has committed, the local document is the record of // Once DocumentStore.Add (below) has committed, the local document is the record of
// truth (per the class doc above) — a ZGW failure past that point is caught, logged, and // truth (per the class doc above) — a ZGW failure past that point is caught, logged, and
// leaves DrcUrl null rather than throwing. DrcUrl == null is already the meaningful "not // leaves DrcUrl null rather than throwing. DrcUrl == null is already the meaningful "not
// registered in ZGW yet" detector LinkToZaak skips on, so no separate flag column is needed // registered in ZGW yet" detector LinkToZaak skips on, so no separate flag column is needed
@@ -89,7 +89,7 @@ public sealed class OpenZaakDocumentSource(
/// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url — /// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url —
/// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have /// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have
/// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case. /// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.
/// WP-60: unlike Upload, a ZGW failure here still throws — DocumentStore.Link (the local /// Unlike Upload, a ZGW failure here still throws — DocumentStore.Link (the local
/// half) already ran above, so the caller (Program.cs's submit endpoint) catching this and /// half) already ran above, so the caller (Program.cs's submit endpoint) catching this and
/// recording it as a flagged divergence is what closes the gap, not a try/catch in here.</summary> /// recording it as a flagged divergence is what closes the gap, not a try/catch in here.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller) public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller)
@@ -15,7 +15,7 @@ public sealed record ZgwPage<T>(
[property: JsonPropertyName("results")] IReadOnlyList<T> Results); [property: JsonPropertyName("results")] IReadOnlyList<T> Results);
/// <summary> /// <summary>
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 /// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (read and
/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the /// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's /// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
/// human zaaktype label, which isn't a value <see cref="AanvraagSummaryDto.Type"/>'s /// human zaaktype label, which isn't a value <see cref="AanvraagSummaryDto.Type"/>'s
@@ -39,7 +39,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListCases(DateTimeOffset now) =>
ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult(); ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult();
/// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param /// <summary>Same read, filtered to one citizen's own zaken via ZGW's rol filter param
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the /// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary> /// system-level one <see cref="ListCases"/> uses.</summary>
public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => public IReadOnlyList<AanvraagSummaryDto> ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) =>
@@ -81,14 +81,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return all; return all;
} }
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ // --- Write path: create a Zaak, then a Status, then a Rol ------------------------
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the /// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
/// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same /// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same
/// reason as <see cref="ListCases"/> (see the ponytail note there) — a submit is already a /// reason as <see cref="ListCases"/> (see the ponytail note there) — a submit is already a
/// single request/response round trip, so no extra concurrency concern. /// single request/response round trip, so no extra concurrency concern.
/// ///
/// WP-60: still no compensating transaction — if any call here throws (after /// Still no compensating transaction — if any call here throws (after
/// <see cref="ZgwHttpClient"/>'s retry gives up), the aanvraag stays Submitted locally with /// <see cref="ZgwHttpClient"/>'s retry gives up), the aanvraag stays Submitted locally with
/// no zaak; rolling it back risks an orphan zaak if the failure landed after the zaak POST /// no zaak; rolling it back risks an orphan zaak if the failure landed after the zaak POST
/// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a /// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a
@@ -108,7 +108,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
Bronorganisatie: options.Bronorganisatie, Bronorganisatie: options.Bronorganisatie,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
Startdatum: DateOnly.FromDateTime(now.UtcDateTime), Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
// WP-73: Aanvraag.Submitted's Referentie is a required, non-nullable member — a // Aanvraag.Submitted's Referentie is a required, non-nullable member — a
// just-submitted aanvraag always has one, so there is nothing left to null-check here. // just-submitted aanvraag always has one, so there is nothing left to null-check here.
Identificatie: aanvraag.Referentie), caller); Identificatie: aanvraag.Referentie), caller);
@@ -140,19 +140,19 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return first.Url; return first.Url;
} }
// --- Write path (WP-66): record a behandelaar's decision as a new zaak status ----------- // --- Write path: record a behandelaar's decision as a new zaak status -----------
/// <summary>POST a new Statussen entry to <paramref name="aanvraag"/>'s zaak, carrying the /// <summary>POST a new Statussen entry to <paramref name="aanvraag"/>'s zaak, carrying the
/// besluit (+ toelichting) in <c>statustoelichting</c> — the harness's catalogus (WP-56) /// besluit (+ toelichting) in <c>statustoelichting</c> — the harness's catalogus
/// provisions only a begin/eind statustype pair per zaaktype, not one per decision outcome /// provisions only a begin/eind statustype pair per zaaktype, not one per decision outcome
/// (a real deployment's Besluiten API is future work, see openzaak-integration.md), so this /// (a real deployment's Besluiten API is future work, see openzaak-integration.md), so this
/// reuses the SAME "last statustype" resolution WP-50's create uses for "first", rather than /// reuses the SAME "last statustype" resolution the create path above uses for "first", rather than
/// adding a besluittype abstraction this catalogus doesn't have. No-op if this aanvraag never /// adding a besluittype abstraction this catalogus doesn't have. No-op if this aanvraag never
/// got a zaak (Zgw was off at submit time, or the create diverged) — same "nothing to do" /// got a zaak (Zgw was off at submit time, or the create diverged) — same "nothing to do"
/// skip <see cref="OpenZaakDocumentSource.LinkToZaak"/> uses for a null zaakUrl. Sync-over-async /// skip <see cref="OpenZaakDocumentSource.LinkToZaak"/> uses for a null zaakUrl. Sync-over-async
/// for the same reason as <see cref="CreateZaak"/>. /// for the same reason as <see cref="CreateZaak"/>.
/// ///
/// WP-60: no compensating transaction here either — the local decision already committed /// No compensating transaction here either — the local decision already committed
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here /// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>), /// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
/// the same way the submit endpoint's create-zaak/document writes are. /// the same way the submit endpoint's create-zaak/document writes are.
@@ -7,15 +7,15 @@ namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of /// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of
/// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the /// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token /// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON. <paramref name="caller"/> is /// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON. <paramref name="caller"/> is
/// optional (WP-53): omitted for calls not tied to one citizen (metadata lookups, the admin /// optional: omitted for calls not tied to one citizen (metadata lookups, the admin
/// cross-owner list), which mint with the BFF's own system identity instead. /// cross-owner list), which mint with the BFF's own system identity instead.
/// </summary> /// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{ {
// WP-60: bounded retry for transport-shaped failures only (gateway restarts, timeouts) — // Bounded retry for transport-shaped failures only (gateway restarts, timeouts) —
// never a substitute for reconciliation. 3 attempts, doubling from 200ms. // never a substitute for reconciliation. 3 attempts, doubling from 200ms.
private const int MaxAttempts = 3; private const int MaxAttempts = 3;
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200);
@@ -73,7 +73,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
continue; continue;
} }
// RB-05/BIO-009: path only — no query string, no response-body snippet. The // BIO-009: path only — no query string, no response-body snippet. The
// BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in // BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in
// its error bodies, so both used to reach a message Program.cs persists as a flagged // its error bodies, so both used to reach a message Program.cs persists as a flagged
// divergence and writes to the application log. Status + path routes the failure; // divergence and writes to the application log. Status + path routes the failure;
@@ -87,7 +87,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
/// <summary>The path without its query string — ZGW filters travel as query parameters and /// <summary>The path without its query string — ZGW filters travel as query parameters and
/// one of them is a BSN (<c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c>), so no /// one of them is a BSN (<c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c>), so no
/// ZGW url may be interpolated into a message that is logged or persisted (RB-05).</summary> /// ZGW url may be interpolated into a message that is logged or persisted.</summary>
private static string Redact(string url) => private static string Redact(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.GetLeftPart(UriPartial.Path) : url.Split('?')[0]; Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.GetLeftPart(UriPartial.Path) : url.Split('?')[0];
@@ -103,8 +103,8 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Every ZGW request must declare a coordinate reference system, even when no geometry is // Every ZGW request must declare a coordinate reference system, even when no geometry is
// involved (Zaak has an optional zaakgeometrie) — a real OpenZaak 412s ("Content-Crs // involved (Zaak has an optional zaakgeometrie) — a real OpenZaak 412s ("Content-Crs
// header ontbreekt") without it. Only surfaced by WP-54's live harness: the fixture/stub // header ontbreekt") without it. Only surfaced by a live harness: the fixture/stub
// tests never modelled this header, so this bug shipped unnoticed since WP-49/50. // tests never modelled this header, so this bug shipped unnoticed for a long time.
req.Headers.Add("Accept-Crs", "EPSG:4326"); req.Headers.Add("Accept-Crs", "EPSG:4326");
if (req.Content is not null) req.Content.Headers.Add("Content-Crs", "EPSG:4326"); if (req.Content is not null) req.Content.Headers.Add("Content-Crs", "EPSG:4326");
} }
+11 -11
View File
@@ -1,14 +1,14 @@
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// Config for connecting to OpenZaak / the ZGW APIs (WP-49), bound from the <c>Zgw</c> /// Config for connecting to OpenZaak / the ZGW APIs, bound from the <c>Zgw</c>
/// section of appsettings. Disabled by default so the POC runs fully offline on the local /// section of appsettings. Disabled by default so the POC runs fully offline on the local
/// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases /// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases
/// from a real OpenZaak. /// from a real OpenZaak.
/// ///
/// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only /// The ZGW standard is FIVE separate services, each its own base URL — the first slice only
/// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API /// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API
/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC); WP-52 /// (ZTC). Create-zaak stayed on those two; a later slice adds the Documenten API (DRC); another
/// adds the Notificaties API (NRC) — inbound only, see <see cref="NotificatieAuthorization"/>. /// adds the Notificaties API (NRC) — inbound only, see <see cref="NotificatieAuthorization"/>.
/// BRC arrives with a later slice, if ever. /// BRC arrives with a later slice, if ever.
/// </summary> /// </summary>
@@ -35,32 +35,32 @@ public sealed class ZgwOptions
public string UserRepresentation { get; init; } = "BIG-register BFF"; public string UserRepresentation { get; init; } = "BIG-register BFF";
/// <summary>Aanvraag <c>Type</c> (registratie/herregistratie/intake) → zaaktype URL (Catalogi), /// <summary>Aanvraag <c>Type</c> (registratie/herregistratie/intake) → zaaktype URL (Catalogi),
/// so create-zaak (WP-50) knows which zaaktype to open per wizard. OpenZaak validates the URL /// so create-zaak knows which zaaktype to open per wizard. OpenZaak validates the URL
/// by fetching it, so an unconfigured or wrong entry fails loudly at create time.</summary> /// by fetching it, so an unconfigured or wrong entry fails loudly at create time.</summary>
public Dictionary<string, string> ZaaktypeUrls { get; init; } = new(); public Dictionary<string, string> ZaaktypeUrls { get; init; } = new();
/// <summary>RSIN of the organisation registering the zaak (<c>bronorganisatie</c>, WP-50).</summary> /// <summary>RSIN of the organisation registering the zaak (<c>bronorganisatie</c>).</summary>
public string Bronorganisatie { get; init; } = ""; public string Bronorganisatie { get; init; } = "";
/// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>, /// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>)
/// WP-50) — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary> /// — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary>
public string VerantwoordelijkeOrganisatie { get; init; } = ""; public string VerantwoordelijkeOrganisatie { get; init; } = "";
/// <summary>Documenten API (DRC) base URL, e.g. <c>https://open-zaak.example/documenten/api/v1</c> (WP-51).</summary> /// <summary>Documenten API (DRC) base URL, e.g. <c>https://open-zaak.example/documenten/api/v1</c>.</summary>
public string DrcBaseUrl { get; init; } = ""; public string DrcBaseUrl { get; init; } = "";
/// <summary>Upload <c>CategoryId</c> (diploma/identiteit/taalvaardigheid/...) → informatieobjecttype /// <summary>Upload <c>CategoryId</c> (diploma/identiteit/taalvaardigheid/...) → informatieobjecttype
/// URL (Catalogi), so create-document (WP-51) knows which type to register per category — /// URL (Catalogi), so create-document knows which type to register per category —
/// the document analogue of <see cref="ZaaktypeUrls"/>.</summary> /// the document analogue of <see cref="ZaaktypeUrls"/>.</summary>
public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new(); public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new();
/// <summary>Notificaties API (NRC) base URL (WP-52) — documentation/provisioning only, no /// <summary>Notificaties API (NRC) base URL — documentation/provisioning only, no
/// code in this app calls it: subscribing an <c>abonnement</c> is a one-time out-of-band /// code in this app calls it: subscribing an <c>abonnement</c> is a one-time out-of-band
/// step (see <c>openzaak-integration.md</c>), not something the BFF does at runtime.</summary> /// step (see <c>openzaak-integration.md</c>), not something the BFF does at runtime.</summary>
public string NrcBaseUrl { get; init; } = ""; public string NrcBaseUrl { get; init; } = "";
/// <summary>The exact <c>Authorization</c> header value NRC must send on every /// <summary>The exact <c>Authorization</c> header value NRC must send on every
/// <c>POST /zgw/notificaties</c> callback (WP-52) — a plain shared secret set into the /// <c>POST /zgw/notificaties</c> callback — a plain shared secret set into the
/// <c>abonnement</c>'s <c>auth</c> field when provisioning, not a JWT. Empty (the default) /// <c>abonnement</c>'s <c>auth</c> field when provisioning, not a JWT. Empty (the default)
/// means every notification is rejected — an unconfigured secret must never mean "accept /// means every notification is rejected — an unconfigured secret must never mean "accept
/// anything".</summary> /// anything".</summary>
@@ -6,7 +6,7 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
/// <summary> /// <summary>
/// Mints the JWT that authenticates the BFF to the ZGW APIs (WP-49). OpenZaak expects a /// Mints the JWT that authenticates the BFF to the ZGW APIs. OpenZaak expects a
/// short-lived HS256 assertion signed with the client secret, carrying <c>iss</c>/ /// short-lived HS256 assertion signed with the client secret, carrying <c>iss</c>/
/// <c>client_id</c> (identity), <c>iat</c> (issued-at), and <c>user_id</c>/ /// <c>client_id</c> (identity), <c>iat</c> (issued-at), and <c>user_id</c>/
/// <c>user_representation</c> (for the ZGW audit trail). There is no OAuth refresh dance — /// <c>user_representation</c> (for the ZGW audit trail). There is no OAuth refresh dance —
@@ -23,12 +23,12 @@ public sealed class ZgwTokenProvider(ZgwOptions options)
/// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary> /// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
public string Mint() => MintCore(options.UserId, options.UserRepresentation); public string Mint() => MintCore(options.UserId, options.UserRepresentation);
/// <summary>Per-request variant (WP-53, extended WP-62): the ZGW audit trail (<c>user_id</c>/ /// <summary>Per-request variant: the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) reflects the acting caller instead of this BFF's static /// <c>user_representation</c>) reflects the acting caller instead of this BFF's static
/// config identity, for any call made on a specific caller's behalf (create zaak, upload, /// config identity, for any call made on a specific caller's behalf (create zaak, upload,
/// link, citizen-scoped list). <see cref="CallerIdentity.SubjectId"/> is the BSN for a /// link, citizen-scoped list). <see cref="CallerIdentity.SubjectId"/> is the BSN for a
/// zorgverlener or the medewerkerId for a medewerker (WP-66 mints this for a besluit write /// zorgverlener or the medewerkerId for a medewerker (a besluit write mints this the
/// the same way, with no further change needed here).</summary> /// same way, with no further change needed here).</summary>
public string Mint(CallerIdentity caller) => MintCore(caller.SubjectId, caller.DisplayName); public string Mint(CallerIdentity caller) => MintCore(caller.SubjectId, caller.DisplayName);
private string MintCore(string userId, string userRepresentation) private string MintCore(string userId, string userRepresentation)
@@ -21,7 +21,7 @@ public sealed record ZgwZaak(
/// <summary> /// <summary>
/// Anti-corruption map: ZGW Zaak → the existing <see cref="AanvraagSummaryDto"/> the FE /// Anti-corruption map: ZGW Zaak → the existing <see cref="AanvraagSummaryDto"/> the FE
/// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype /// already renders. This is where "URL as identity" and the cross-service zaaktype
/// join get flattened away, so nothing downstream (the FE) sees ZGW shapes. /// join get flattened away, so nothing downstream (the FE) sees ZGW shapes.
/// </summary> /// </summary>
public static class ZgwZaakMapper public static class ZgwZaakMapper
@@ -45,7 +45,7 @@ public static class ZgwZaakMapper
Id: Uuid(z.Url), Id: Uuid(z.Url),
Type: zaaktypeLabel, Type: zaaktypeLabel,
Status: status, Status: status,
DocumentIds: Array.Empty<string>(), // zaak↔document links arrive with WP-51 (DRC) DocumentIds: Array.Empty<string>(), // zaak↔document links arrive with a later slice (DRC)
CreatedAt: created, CreatedAt: created,
UpdatedAt: updated, UpdatedAt: updated,
SubmittedAt: created, SubmittedAt: created,
@@ -57,7 +57,7 @@ public static class ZgwZaakMapper
private static string Iso(DateOnly d) => private static string Iso(DateOnly d) =>
d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o"); d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o");
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling /// <summary>Status for a zaak that was JUST created — always the open/InBehandeling
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary> /// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) => public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
AanvraagStatus.InBehandeling(identificatie, manual: true).ToDto(); AanvraagStatus.InBehandeling(identificatie, manual: true).ToDto();
+1 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"_Zgw": "WP-49..52: set Enabled=true + the URLs/credentials/RSINs/type maps to source, create and document cases against a real OpenZaak; NrcBaseUrl/NotificatieAuthorization configure the inbound notificaties webhook. Off = local SQLite store (offline POC default).", "_Zgw": "Set Enabled=true + the URLs/credentials/RSINs/type maps to source, create and document cases against a real OpenZaak; NrcBaseUrl/NotificatieAuthorization configure the inbound notificaties webhook. Off = local SQLite store (offline POC default).",
"Zgw": { "Zgw": {
"Enabled": false, "Enabled": false,
"ZrcBaseUrl": "", "ZrcBaseUrl": "",
@@ -14,7 +14,7 @@ public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<Te
private async Task<AanvraagDetailDto> Create(string type = "registratie") private async Task<AanvraagDetailDto> Create(string type = "registratie")
{ {
// WP-35: one Concept per type is now server-enforced, and these tests share one DB // One Concept per type is now server-enforced, and these tests share one DB
// (IClassFixture). Clear any leftover Concept so each test starts from a clean slate. // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate.
var existing = await List(); var existing = await List();
Assert.NotNull(existing); Assert.NotNull(existing);
@@ -103,7 +103,7 @@ public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
} }
// --- WP-35: one Concept per case type (server-enforced) --- // --- One Concept per case type (server-enforced) ---
[Fact] [Fact]
public async Task Creating_a_second_concept_of_the_same_type_conflicts() public async Task Creating_a_second_concept_of_the_same_type_conflicts()
@@ -146,7 +146,7 @@ public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
} }
// --- WP-53: citizen-scoping — GET /aanvragen must never leak across identities. --- // --- Citizen-scoping — GET /aanvragen must never leak across identities. ---
[Fact] [Fact]
public async Task Applications_are_scoped_to_the_caller_bsn() public async Task Applications_are_scoped_to_the_caller_bsn()
@@ -181,7 +181,7 @@ public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<Te
} }
} }
// --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must // --- A citizen may only reference their own uploads — submit/draft-sync must
// reject a foreign documentId rather than silently attaching it. --- // reject a foreign documentId rather than silently attaching it. ---
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId) private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
@@ -9,8 +9,8 @@ using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance; namespace BigRegister.Tests.Acceptance;
/// <summary> /// <summary>
/// Behaviour-level tests for the besluit lifecycle (WP-65b/66/68), built through the /// Behaviour-level tests for the besluit lifecycle, built through the
/// <see cref="Given"/> type-state builder (WP-70) rather than the full wizard/upload dance /// <see cref="Given"/> type-state builder rather than the full wizard/upload dance
/// <see cref="BeoordelingTests"/> uses — a fixture that's already Submitted (or already /// <see cref="BeoordelingTests"/> uses — a fixture that's already Submitted (or already
/// Decided) is a two-line Given, not fifteen. Each test persists its own Given-built /// Decided) is a two-line Given, not fifteen. Each test persists its own Given-built
/// <see cref="Aanvraag"/> straight into the isolated per-class SQLite file (no HTTP round trip /// <see cref="Aanvraag"/> straight into the isolated per-class SQLite file (no HTTP round trip
@@ -8,8 +8,8 @@ using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance; namespace BigRegister.Tests.Acceptance;
/// <summary> /// <summary>
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over /// Behaviour-level tests for the scholing-threshold enforcement over
/// <c>POST /aanvragen/{id}/submit</c> (the wizard's real path — WP-72 deleted the legacy /// <c>POST /aanvragen/{id}/submit</c> (the wizard's real path — a later change deleted the legacy
/// <c>POST /intakes</c> endpoint this once also covered). Built through the <see /// <c>POST /intakes</c> endpoint this once also covered). Built through the <see
/// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather /// cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/> rather
/// than the full wizard/upload dance — the builder's default owner IS <see /// than the full wizard/upload dance — the builder's default owner IS <see
@@ -5,7 +5,7 @@ using BigRegister.Api.Contracts;
namespace BigRegister.Tests.Acceptance; namespace BigRegister.Tests.Acceptance;
/// <summary> /// <summary>
/// Contract test for the FE/BE seam on phone-number stripping (WP-75). Both sides share /// Contract test for the FE/BE seam on phone-number stripping. Both sides share
/// the same format regex (<c>^0\d{9}$</c>) but, until this test, diverged on what they /// the same format regex (<c>^0\d{9}$</c>) but, until this test, diverged on what they
/// strip before checking it: the FE's <c>parseTelefoonnummer</c> /// strip before checking it: the FE's <c>parseTelefoonnummer</c>
/// (registratie/domain/value-objects/telefoonnummer.ts) also drops parentheses and maps a /// (registratie/domain/value-objects/telefoonnummer.ts) also drops parentheses and maps a
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-36: admin cross-owner case list + admin delete, gated by `cases:manage`. /// Admin cross-owner case list + admin delete, gated by `cases:manage`.
public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
@@ -35,7 +35,7 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
list.EnsureSuccessStatusCode(); list.EnsureSuccessStatusCode();
var cases = (await list.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!; var cases = (await list.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = cases.Single(x => x.Id == a.Id); var mine = cases.Single(x => x.Id == a.Id);
// RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is // BIO-003: the owner is carried, but masked — it is a BSN, and this list is
// read by someone who is not the subject. // read by someone who is not the subject.
Assert.Equal("******782", mine.Owner); Assert.Equal("******782", mine.Owner);
Assert.DoesNotContain(DocumentStore.DemoOwner, mine.Owner); Assert.DoesNotContain(DocumentStore.DemoOwner, mine.Owner);
@@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-41: the persisted authz/PII-reveal audit trail is queryable, data-minimised (no PII). /// The persisted authz/PII-reveal audit trail is queryable, data-minimised (no PII).
public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
@@ -59,7 +59,7 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer"); Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer");
} }
/// RB-07/BIO-007: the trail used to record only denials, so `/beheer/audit` could answer /// BIO-007: the trail used to record only denials, so `/beheer/audit` could answer
/// "who was turned away" but not "who changed this" — for a register whose integrity is the /// "who was turned away" but not "who changed this" — for a register whose integrity is the
/// product, the wrong half. Every gate now audits the real decision. /// product, the wrong half. Every gate now audits the real decision.
[Fact] [Fact]
@@ -69,10 +69,10 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"); Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin");
} }
/// RB-08/BIO-003: the admin upload delete used to be gated by a standalone X-Admin /// BIO-003: the admin upload delete used to be gated by a standalone X-Admin
/// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through /// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through
/// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases /// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases
/// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases" /// endpoint gets, for free. `CasesAdmin` audits under a fixed "cases"
/// resource shared with the other admin-cases endpoints, so this asserts a **count** /// resource shared with the other admin-cases endpoints, so this asserts a **count**
/// increase — reading the store directly (not via `GET /admin/audit`, itself a /// increase — reading the store directly (not via `GET /admin/audit`, itself a
/// `CasesAdmin` endpoint that would write its own row and confound the count) — /// `CasesAdmin` endpoint that would write its own row and confound the count) —
@@ -120,7 +120,7 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny"); Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny");
} }
/// RB-02/BIO-008: the schema test below asserts on **column names**, so a BSN inside a /// BIO-008: the schema test below asserts on **column names**, so a BSN inside a
/// column called `Resource` was invisible to it — and one was there, concatenated as /// column called `Resource` was invisible to it — and one was there, concatenated as
/// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents /// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents
/// promise this trail holds no PII; this is the test that makes the promise checkable. /// promise this trail holds no PII; this is the test that makes the promise checkable.
@@ -82,7 +82,7 @@ public class AuthzTests
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer); Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer);
} }
// --- CanBeoordelen (WP-62) -------------------------------------------------------------- // --- CanBeoordelen --------------------------------------------------------------
[Fact] [Fact]
public void CanBeoordelen_true_for_a_medewerker_with_the_behandelaar_rol() public void CanBeoordelen_true_for_a_medewerker_with_the_behandelaar_rol()
@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-65 (read side): one aanvraag's case-treatment detail, gated by the same medewerker /// One aanvraag's case-treatment detail (read side), gated by the same medewerker
/// capability (`CanBeoordelen`, WP-62) as the werkvoorraad list (WP-64). /// capability (`CanBeoordelen`) as the werkvoorraad list.
public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
@@ -152,7 +152,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag); Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
// RB-07/BIO-007: the gate records that a behandelaar was allowed to act; this records // BIO-007: the gate records that a behandelaar was allowed to act; this records
// what they decided, which is the question /beheer/audit exists to answer. // what they decided, which is the question /beheer/audit exists to answer.
Assert.Contains(AuthzAuditStore.List(), e => Assert.Contains(AuthzAuditStore.List(), e =>
e.Action == "aanvraag:besluit" && e.Decision == "allow" && e.Action == "aanvraag:besluit" && e.Decision == "allow" &&
@@ -222,7 +222,7 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
} }
} }
// WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so // The transition-legality check now runs inside RecordBesluit's write lock, so
// two besluiten racing on the same still-open aanvraag can't both pass the check before // two besluiten racing on the same still-open aanvraag can't both pass the check before
// either writes — exactly one commits, the other sees the now-terminal status. // either writes — exactly one commits, the other sees the now-terminal status.
[Fact] [Fact]
@@ -28,7 +28,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return new SaveBriefRequest(sections); return new SaveBriefRequest(sections);
} }
/// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that /// `GET /brief` no longer seeds a brief on first call, so every test that
/// needs one present creates it explicitly through `POST /brief/reset` /// needs one present creates it explicitly through `POST /brief/reset`
/// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses. /// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses.
private async Task<BriefDto> SeedBrief() private async Task<BriefDto> SeedBrief()
@@ -48,7 +48,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return req; return req;
} }
// --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. --- // --- CQ-007: GET /brief is a pure query — it must not create a row. ---
[Fact] [Fact]
public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner() public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
@@ -181,8 +181,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
Assert.NotNull(submitted); Assert.NotNull(submitted);
Assert.Equal("submitted", submitted.Brief.Status.Tag); Assert.Equal("submitted", submitted.Brief.Status.Tag);
// RB-07/BIO-007: the allow side of the transition leaves a row, not just a log line. // BIO-007: the allow side of the transition leaves a row, not just a log line.
// Resource is the bare "brief" — never the owner's BSN (RB-02). // Resource is the bare "brief" — never the owner's BSN.
Assert.Contains(AuthzAuditStore.List(), Assert.Contains(AuthzAuditStore.List(),
e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief"); e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief");
} }
@@ -14,9 +14,9 @@ public static class TestIdentities
} }
/// <summary> /// <summary>
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70; simplified at WP-73). "Build /// Type-state test-data builder for <see cref="Aanvraag"/>. "Build
/// test data through the same door production code uses" — <see cref="Aanvraag"/> itself is now /// test data through the same door production code uses" — <see cref="Aanvraag"/> itself is now
/// the closed Concept/Submitted/Decided union WP-73 introduced, so this builder no longer needs /// the closed Concept/Submitted/Decided union, so this builder no longer needs
/// to mirror production's guards (step-index bounds, "Afwijzen needs a toelichting") by hand — /// to mirror production's guards (step-index bounds, "Afwijzen needs a toelichting") by hand —
/// it just calls the real nested constructors/required members, which enforce them. A call that /// it just calls the real nested constructors/required members, which enforce them. A call that
/// would build an illegal Aanvraag (e.g. deciding a still-Concept aanvraag, or an Afwijzen with /// would build an illegal Aanvraag (e.g. deciding a still-Concept aanvraag, or an Afwijzen with
@@ -61,7 +61,7 @@ public sealed class ConceptAanvraag
/// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors /// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors
/// <c>ApplicationStore.Submit</c>), so a fixture built this way can never hit the /// <c>ApplicationStore.Submit</c>), so a fixture built this way can never hit the
/// null-forgiving derefs the pre-WP-73 flat Aanvraag needed (there's nothing to force any /// null-forgiving derefs the earlier flat Aanvraag needed (there's nothing to force any
/// more: both are required, non-null members of <see cref="Aanvraag.Submitted"/>). /// more: both are required, non-null members of <see cref="Aanvraag.Submitted"/>).
public SubmittedAanvraag Submitted(bool autoApprovable = false) => new(_type, _owner, autoApprovable); public SubmittedAanvraag Submitted(bool autoApprovable = false) => new(_type, _owner, autoApprovable);
@@ -110,7 +110,7 @@ public sealed class SubmittedAanvraag
return this; return this;
} }
/// <summary>Records a behandelaar's decision. Unlike the pre-WP-73 builder, there is no /// <summary>Records a behandelaar's decision. Unlike the earlier builder, there is no
/// hand-written toelichting guard mirroring <c>BeoordelingRules.RequiresToelichting</c> any /// hand-written toelichting guard mirroring <c>BeoordelingRules.RequiresToelichting</c> any
/// more — <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/> /// more — <see cref="Aanvraag.Decided.Afgewezen"/>/<see cref="Aanvraag.Decided.MeerInfoGevraagd"/>
/// simply have a `required string Toelichting` member; the null-coalescing throw below is the /// simply have a `required string Toelichting` member; the null-coalescing throw below is the
@@ -186,7 +186,7 @@ public sealed class SubmittedAanvraag
/// (<see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>); a fixture that /// (<see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>); a fixture that
/// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from /// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would. Just a one-line /// <see cref="Given.Concept"/> again, exactly as a real second request would. Just a one-line
/// wrapper around the already-fully-built <see cref="Aanvraag.Decided"/> value — WP-73 moved /// wrapper around the already-fully-built <see cref="Aanvraag.Decided"/> value — construction moved
/// all the actual construction (and its invariant enforcement) into /// all the actual construction (and its invariant enforcement) into
/// <see cref="SubmittedAanvraag.Decided"/> itself, so there's nothing left for this type to do /// <see cref="SubmittedAanvraag.Decided"/> itself, so there's nothing left for this type to do
/// except keep <c>.Decided(...).Build()</c> a valid two-call chain for the existing test /// except keep <c>.Decided(...).Build()</c> a valid two-call chain for the existing test
@@ -4,9 +4,9 @@ namespace BigRegister.Tests.Domain;
public class ApplicationRuleTests public class ApplicationRuleTests
{ {
// WP-63: the published lifecycle (ADR-0002) must name exactly these five tags, in this // The published lifecycle (ADR-0002) must name exactly these five tags, in this
// order — ToStatusDto's string literals must keep matching Enum.ToString(), and Ingediend/ // order — ToStatusDto's string literals must keep matching Enum.ToString(), and Ingediend/
// MeerInfoGevraagd (unreachable until WP-65 adds the behandelaar transition) stay defined. // MeerInfoGevraagd (unreachable until a later change adds the behandelaar transition) stay defined.
[Fact] [Fact]
public void AanvraagStatusTag_covers_the_published_lifecycle() public void AanvraagStatusTag_covers_the_published_lifecycle()
{ {
@@ -16,7 +16,7 @@ public class BeoordelingRuleTests
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) => public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
Assert.Equal(expected, BeoordelingRules.CanDecide(tag)); Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check. // The toelichting rule, moved here from an inline endpoint check.
[Theory] [Theory]
[InlineData(Besluit.Goedkeuren, false)] [InlineData(Besluit.Goedkeuren, false)]
[InlineData(Besluit.Afwijzen, true)] [InlineData(Besluit.Afwijzen, true)]
@@ -24,12 +24,12 @@ public class BeoordelingRuleTests
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) => public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit)); Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag — // The transition table at the AGGREGATE level, not just against a bare tag —
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal // an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the // StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the // domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit. // endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
// WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with // Built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with
// no toelichting simply couldn't compile as a fixture here. // no toelichting simply couldn't compile as a fixture here.
[Theory] [Theory]
[InlineData(Besluit.Goedkeuren)] [InlineData(Besluit.Goedkeuren)]
@@ -4,7 +4,7 @@ namespace BigRegister.Tests.Domain;
public class IntakeRuleTests public class IntakeRuleTests
{ {
// The arguments ARE the Given (WP-69/bdd.mdx) — these degenerate to When/Then. // The arguments ARE the Given (bdd.mdx) — these degenerate to When/Then.
[Fact] [Fact]
public void Below_threshold_with_no_answer_is_incomplete() => public void Below_threshold_with_no_answer_is_incomplete() =>
@@ -87,7 +87,7 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
new { telefoon = "nope" }); new { telefoon = "nope" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
// The Submit helper's rejection shape — was asserted through POST /registrations until // The Submit helper's rejection shape — was asserted through POST /registrations until
// RB-06 deleted it; /change-requests is the other endpoint on the same helper. // that endpoint was deleted; /change-requests is the other endpoint on the same helper.
var contentType = res.Content.Headers.ContentType; var contentType = res.Content.Headers.ContentType;
Assert.NotNull(contentType); Assert.NotNull(contentType);
Assert.Contains("application/problem+json", contentType.ToString()); Assert.Contains("application/problem+json", contentType.ToString());
@@ -200,8 +200,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
public async Task User_delete_blocked_with_409_once_linked_to_submission() public async Task User_delete_blocked_with_409_once_linked_to_submission()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
// Through the real submit path (RB-06 deleted POST /registrations, which was the only // Through the real submit path (POST /registrations was the only other caller of
// other caller of DocumentStore.Link and had no ownership guard on it). // DocumentStore.Link and had no ownership guard on it; it has since been deleted).
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" }); var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!; var aanvraag = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit", var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{aanvraag.Id}/submit",
@@ -213,7 +213,7 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
[Fact] [Fact]
public async Task Admin_delete_requires_admin_role() public async Task Admin_delete_requires_admin_role()
{ {
// RB-08: routed through CasesAdmin (cases:manage), like the other admin-cases // Routed through CasesAdmin (cases:manage), like the other admin-cases
// endpoints, not the standalone X-Admin header this used to accept. // endpoints, not the standalone X-Admin header this used to accept.
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-47: runtime feature flags — catalog in code, admin-toggled, server-enforced. /// Runtime feature flags — catalog in code, admin-toggled, server-enforced.
public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
@@ -45,7 +45,7 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie); Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
} }
// RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so // BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so
// caller B replaying caller A's Idempotency-Key got caller A's cached reference back — // caller B replaying caller A's Idempotency-Key got caller A's cached reference back —
// a cross-caller leak of a value caller B never submitted. The store now keys on // a cross-caller leak of a value caller B never submitted. The store now keys on
// "{SubjectId}:{idemKey}", so the same header value from two different callers is two // "{SubjectId}:{idemKey}", so the same header value from two different callers is two
@@ -1,7 +1,7 @@
<!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css the FEBE letter-rendering CONTRACT (WP-24/WP-25). <!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css the FEBE letter-rendering CONTRACT.
* *
* One stylesheet, two consumers: the FE letter canvas loads it via <link> * One stylesheet, two consumers: the FE letter canvas loads it via <link>
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25) * (index.html + Storybook preview-head), the backend HTML renderer
* inlines this same file. Its class-parity test is the fence against drift. * inlines this same file. Its class-parity test is the fence against drift.
* *
* Class vocabulary: .letter, .letter__letterhead, .letter__body, * Class vocabulary: .letter, .letter__letterhead, .letter__body,
@@ -6,7 +6,7 @@ using BigRegister.Domain.Letters;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-25's fence against drift between the backend renderer and the FE letter /// The fence against drift between the backend renderer and the FE letter
/// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity /// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity
/// check that every `letter`-prefixed class the renderer emits exists in the /// check that every `letter`-prefixed class the renderer emits exists in the
/// shared `public/letter.css` contract. Neither test launches a browser. /// shared `public/letter.css` contract. Neither test launches a browser.
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-52: the inbound Notificaties (NRC) webhook — auth accept/reject + the audit trail it /// The inbound Notificaties (NRC) webhook — auth accept/reject + the audit trail it
/// writes via AuthzAuditStore (no Principal exists for an NRC caller, so this doesn't go /// writes via AuthzAuditStore (no Principal exists for an NRC caller, so this doesn't go
/// through the Principal-shaped AuditAuthz helper the user-facing endpoints use). /// through the Principal-shaped AuditAuthz helper the user-facing endpoints use).
public class NotificatieTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class NotificatieTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
@@ -6,7 +6,7 @@ using BigRegister.Domain.Authorization;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// Exercises the OpenZaak document source against a stub HttpMessageHandler (WP-51): an /// Exercises the OpenZaak document source against a stub HttpMessageHandler: an
/// upload registers a DRC enkelvoudiginformatieobject, and linking to a zaak POSTs a /// upload registers a DRC enkelvoudiginformatieobject, and linking to a zaak POSTs a
/// zaakinformatieobject per document once a zaak URL is known. /// zaakinformatieobject per document once a zaak URL is known.
/// </summary> /// </summary>
@@ -48,7 +48,7 @@ public class OpenZaakDocumentSourceTests
Assert.Equal("local-1", response.LocalId); Assert.Equal("local-1", response.LocalId);
Assert.NotEmpty(response.DocumentId); Assert.NotEmpty(response.DocumentId);
// Registered locally too (dual-write, same reasoning as CreateZaak/WP-50) — content // Registered locally too (dual-write, same reasoning as CreateZaak) — content
// preview/download keeps working regardless of Zgw:Enabled. // preview/download keeps working regardless of Zgw:Enabled.
var stored = DocumentStore.Get(response.DocumentId); var stored = DocumentStore.Get(response.DocumentId);
Assert.NotNull(stored); Assert.NotNull(stored);
@@ -59,7 +59,7 @@ public class OpenZaakDocumentSourceTests
Assert.Contains("123443210", body); // bronorganisatie Assert.Contains("123443210", body); // bronorganisatie
Assert.Contains("paspoort.pdf", body); Assert.Contains("paspoort.pdf", body);
Assert.Contains(Convert.ToBase64String("%PDF-1.4 fake"u8.ToArray()), body); // inhoud Assert.Contains(Convert.ToBase64String("%PDF-1.4 fake"u8.ToArray()), body); // inhoud
// WP-59: "identiteit" is mapped to "vertrouwelijk" in the confidentialiteit stamdata. // "identiteit" is mapped to "vertrouwelijk" in the confidentialiteit stamdata.
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"vertrouwelijk\"", body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"vertrouwelijk\"", body);
} }
@@ -78,7 +78,7 @@ public class OpenZaakDocumentSourceTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
} }
// WP-60: once DocumentStore.Add has committed, a ZGW-side failure (config gap or transport) // Once DocumentStore.Add has committed, a ZGW-side failure (config gap or transport)
// no longer throws — the local document is authoritative and DrcUrl stays null (the same // no longer throws — the local document is authoritative and DrcUrl stays null (the same
// detector LinkToZaak already skips on for pre-Zgw documents). // detector LinkToZaak already skips on for pre-Zgw documents).
@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted, /// The one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted,
/// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub /// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub
/// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>, /// HttpMessageHandler every other Zgw test (<see cref="ZgwZaakMapperTests"/>,
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c> /// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
@@ -36,7 +36,7 @@ public class OpenZaakIntegrationTests
.UseSetting("Zgw:ClientId", "bigregister-test") .UseSetting("Zgw:ClientId", "bigregister-test")
.UseSetting("Zgw:Secret", "bigregister-test-secret") .UseSetting("Zgw:Secret", "bigregister-test-secret")
.UseSetting("Zgw:UserId", "bigregister-test") .UseSetting("Zgw:UserId", "bigregister-test")
.UseSetting("Zgw:UserRepresentation", "WP-54 integration test") .UseSetting("Zgw:UserRepresentation", "OpenZaak integration test")
.UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl)); .UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl));
} }
@@ -182,7 +182,7 @@ public class OpenZaakZaakSourceTests
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller)); Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
} }
// --- WP-66: besluit write (a status transition on an existing zaak) -------------------- // --- Besluit write (a status transition on an existing zaak) --------------------
[Fact] [Fact]
public void RecordBesluit_posts_the_last_statustype_with_besluit_and_toelichting() public void RecordBesluit_posts_the_last_statustype_with_besluit_and_toelichting()
@@ -308,7 +308,7 @@ public class OpenZaakZaakSourceTests
Assert.Throws<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller)); Assert.Throws<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
} }
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path --- // --- Bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
private static (ZgwOptions options, Aanvraag.Submitted aanvraag, CallerIdentity caller) CreateZaakFixture() private static (ZgwOptions options, Aanvraag.Submitted aanvraag, CallerIdentity caller) CreateZaakFixture()
{ {
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the /// Org templates: admin-only endpoints, draft→publish versioning, and the
/// sent-brief immutability invariant (pin at send, republish touches unsent only). /// sent-brief immutability invariant (pin at send, republish touches unsent only).
/// Same reset discipline as BriefEndpointTests — the stores are process-global. /// Same reset discipline as BriefEndpointTests — the stores are process-global.
/// </summary> /// </summary>
@@ -58,7 +58,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_increments_the_version() public async Task Publish_increments_the_version()
{ {
ResetStores(); ResetStores();
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). // One unsent brief for this sub-org (GET no longer seeds — create explicitly).
await _client.PostAsync("/api/v1/brief/reset", null); await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_appends_to_the_version_history() public async Task Publish_appends_to_the_version_history()
{ {
ResetStores(); ResetStores();
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly await _client.PostAsync("/api/v1/brief/reset", null); // GET no longer seeds — create explicitly
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
@@ -87,7 +87,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_counts_the_unsent_briefs_it_affects() public async Task Publish_counts_the_unsent_briefs_it_affects()
{ {
ResetStores(); ResetStores();
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). // One unsent brief for this sub-org (GET no longer seeds — create explicitly).
await _client.PostAsync("/api/v1/brief/reset", null); await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
@@ -154,7 +154,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
private async Task WalkBriefToSentThenRepublish() private async Task WalkBriefToSentThenRepublish()
{ {
ResetStores(); ResetStores();
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief; var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
@@ -211,7 +211,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Admin_cannot_slip_into_the_brief_review_flow() public async Task Admin_cannot_slip_into_the_brief_review_flow()
{ {
ResetStores(); ResetStores();
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief; var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-25: the two HTML preview endpoints. Both are excluded from the OpenAPI doc /// The two HTML preview endpoints. Both are excluded from the OpenAPI doc
/// (see the drift check in the API-client generation step) — these tests hit them /// (see the drift check in the API-client generation step) — these tests hit them
/// as plain HTTP, the same way the hand-written FE fetch does. /// as plain HTTP, the same way the hand-written FE fetch does.
/// </summary> /// </summary>
@@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark() public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{ {
ResetStores(); ResetStores();
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly await _client.PostAsync("/api/v1/brief/reset", null); // GET no longer seeds — create explicitly
var res = await _client.GetAsync("/api/v1/brief/preview"); var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
@@ -54,7 +54,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish() public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{ {
ResetStores(); ResetStores();
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief; var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit")); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
@@ -4,11 +4,11 @@ using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// RB-12/BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the /// BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the
/// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing /// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing
/// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints /// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints
/// with no gate at all) are exactly the failure mode this test is a safety net for — and it is /// with no gate at all) are exactly the failure mode this test is a safety net for — and it is
/// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in /// the safety net a 900-line `Program.cs` reorder leans on, so its value is entirely in
/// being hard to fool. /// being hard to fool.
/// ///
/// Every mapped route must be accounted for exactly one of two ways: /// Every mapped route must be accounted for exactly one of two ways:
@@ -48,14 +48,14 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt
new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."), new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."),
new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."), new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."),
new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."), new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."),
new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design (WP-47) — only the PUT toggle is admin-gated."), new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design — only the PUT toggle is admin-gated."),
new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."), new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."),
// --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()), // --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()),
// not a role-only admin wrapper because the boundary is resource ownership, not a role. --- // not a role-only admin wrapper because the boundary is resource ownership, not a role. ---
new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."), new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."),
new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."), new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."),
new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."),
new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."),
new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."),
new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
@@ -72,14 +72,14 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt
// enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's // enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's
// Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers, // Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers,
// not a missing one. --- // not a missing one. ---
new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."), new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent."),
new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."), new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."),
new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."), new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."),
new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."), new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."),
new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."), new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."),
new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."), new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."),
new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."), new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."),
new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."), new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent; hand-written FE fetch."),
new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."), new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."),
]; ];
@@ -59,7 +59,7 @@ public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFi
Assert.Empty(table.Rows); Assert.Empty(table.Rows);
} }
/// RB-16/BIO-019: DateOnly.Parse used to throw FormatException on unparseable input, /// BIO-019: DateOnly.Parse used to throw FormatException on unparseable input,
/// surfacing as an unhandled 500 instead of the 400-with-problem-details every other /// surfacing as an unhandled 500 instead of the 400-with-problem-details every other
/// bad-input check in this endpoint file returns. /// bad-input check in this endpoint file returns.
[Fact] [Fact]
@@ -12,8 +12,8 @@ namespace BigRegister.Tests;
/// </summary> /// </summary>
public class StamdataValidationTests public class StamdataValidationTests
{ {
/// Declared references INTO stamdata keys — the FK-like invariants the build gate enforces /// Declared references INTO stamdata keys — the FK-like invariants the build gate enforces.
/// (WP-48). Add an entry when a consumer starts depending on a stamdata key; the gate then /// Add an entry when a consumer starts depending on a stamdata key; the gate then
/// fails a delete/rename/expire that orphans it. Resolvers use the "valid today" views, so /// fails a delete/rename/expire that orphans it. Resolvers use the "valid today" views, so
/// expiring a row (geldigTot in the past) that current data still references also fails — /// expiring a row (geldigTot in the past) that current data still references also fails —
/// which steers the editor toward closing validity only once nothing current relies on it. /// which steers the editor toward closing validity only once nothing current relies on it.
@@ -23,7 +23,7 @@ public class StamdataValidationTests
private static readonly IReadOnlySet<string> BeroepCodes = private static readonly IReadOnlySet<string> BeroepCodes =
StamdataFile.Load<Beroep>("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal); StamdataFile.Load<Beroep>("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal);
// Every document category id that exists across any wizard (WP-59's confidentialiteit // Every document category id that exists across any wizard (the confidentialiteit
// table points at these) — "org-logo" resolves too, even though it's deliberately absent // table points at these) — "org-logo" resolves too, even though it's deliberately absent
// from the confidentialiteit table itself (falls back to "openbaar"). // from the confidentialiteit table itself (falls back to "openbaar").
private static readonly IReadOnlySet<string> DocumentCategoryIds = new[] { "registratie", "herregistratie", "org-template" } private static readonly IReadOnlySet<string> DocumentCategoryIds = new[] { "registratie", "herregistratie", "org-template" }
@@ -38,7 +38,7 @@ public class StamdataValidationTests
SeedData.Diplomas.Select(d => d.Opleiding), SeedData.Diplomas.Select(d => d.Opleiding),
key => Professions.ByProgram.ContainsKey(key)), key => Professions.ByProgram.ContainsKey(key)),
// Stamdata → stamdata references: two tables point at beroepen.code, so deleting or // Stamdata → stamdata references: two tables point at beroepen.code, so deleting or
// renaming a beroep that either still uses fails the build (WP-48 gate, generalized). // renaming a beroep that either still uses fails the build (a stamdata gate, generalized).
new StamdataRef( new StamdataRef(
"Opleiding.beroep → beroepen.code", "Opleiding.beroep → beroepen.code",
StamdataFile.Load<Opleiding>("opleidingen").Select(o => o.Beroep), StamdataFile.Load<Opleiding>("opleidingen").Select(o => o.Beroep),
@@ -47,7 +47,7 @@ public class StamdataValidationTests
"Specialisme.beroep → beroepen.code", "Specialisme.beroep → beroepen.code",
StamdataFile.Load<Specialisme>("specialismen").Select(s => s.Beroep), StamdataFile.Load<Specialisme>("specialismen").Select(s => s.Beroep),
key => BeroepCodes.Contains(key)), key => BeroepCodes.Contains(key)),
// WP-59: a confidentialiteit row for a category that no wizard ever asks for is dead // A confidentialiteit row for a category that no wizard ever asks for is dead
// config — fail the build rather than let it silently rot. // config — fail the build rather than let it silently rot.
new StamdataRef( new StamdataRef(
"DocumentConfidentialiteit.CategoryId → a real document category", "DocumentConfidentialiteit.CategoryId → a real document category",
@@ -6,10 +6,10 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-53 (extended WP-62): the dev stub identity provider — role from X-Role (unchanged /// The dev stub identity provider — role from X-Role (unchanged
/// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the /// behaviour, applies to either actor kind), subject BSN from X-Subject defaulting to the
/// single seeded citizen so every existing request (none of which send X-Subject) resolves /// single seeded citizen so every existing request (none of which send X-Subject) resolves
/// exactly as before this WP. X-Medewerker (+ X-Rollen) selects the medewerker actor kind. /// exactly as before. X-Medewerker (+ X-Rollen) selects the medewerker actor kind.
public class StubIdentityProviderTests public class StubIdentityProviderTests
{ {
private static CallerIdentity Resolve( private static CallerIdentity Resolve(
@@ -96,7 +96,7 @@ public class StubIdentityProviderTests
Assert.Equal(PrincipalRole.Admin, caller.Role); Assert.Equal(PrincipalRole.Admin, caller.Role);
} }
/// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this /// BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this
/// stub's own contract stays non-nullable — it is a developer convenience that always invents /// stub's own contract stays non-nullable — it is a developer convenience that always invents
/// a caller, never a source of "no identity" itself. A request with genuinely no headers at /// a caller, never a source of "no identity" itself. A request with genuinely no headers at
/// all still resolves to the seeded citizen, unchanged. /// all still resolves to the seeded citizen, unchanged.
@@ -107,7 +107,7 @@ public class StubIdentityProviderTests
} }
} }
/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is /// BIO-002: in Production, StubIdentityProvider is not registered at all (it is
/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet — /// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet —
/// so a Production build must fail at startup rather than silently resolving every request to /// so a Production build must fail at startup rather than silently resolving every request to
/// the seeded citizen (the failure mode BIO-002 documents). /// the seeded citizen (the failure mode BIO-002 documents).
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// RB-15/BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the /// BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the
/// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were /// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were
/// reachable in every environment, including a real deployment. Both are now gated behind /// reachable in every environment, including a real deployment. Both are now gated behind
/// `app.Environment.IsDevelopment()`. /// `app.Environment.IsDevelopment()`.
@@ -16,26 +16,26 @@ public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture
public async Task Swagger_document_is_served_in_development() public async Task Swagger_document_is_served_in_development()
{ {
// The default test environment (WebApplicationFactory<T> defaults to "Development" when // The default test environment (WebApplicationFactory<T> defaults to "Development" when
// nothing overrides it — same fact RB-09's implementation note relies on) — this is the // nothing overrides it — same fact a related implementation note relies on) — this is the
// regression guard that the gate didn't also break the documented `npm run gen:api` / // regression guard that the gate didn't also break the documented `npm run gen:api` /
// local-dev-Swagger-UI experience. // local-dev-Swagger-UI experience.
var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json"); var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.OK, res.StatusCode); Assert.Equal(HttpStatusCode.OK, res.StatusCode);
} }
/// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which /// Production cannot boot at all today (no real IIdentityProvider exists yet), which
/// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain /// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain
/// `UseEnvironment("Production")` host never reaches this middleware to prove the gate /// `UseEnvironment("Production")` host never reaches this middleware to prove the gate
/// itself works, only that the whole app refuses to start. This uses a third environment /// itself works, only that the whole app refuses to start. This uses a third environment
/// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` — /// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` —
/// the one thing Program.cs doesn't register outside those two branches — so the host /// the one thing Program.cs doesn't register outside those two branches — so the host
/// actually boots and this test exercises the real gate, not RB-09's unrelated startup throw. /// actually boots and this test exercises the real gate, not that unrelated startup throw.
[Fact] [Fact]
public async Task Swagger_document_is_not_served_outside_development() public async Task Swagger_document_is_not_served_outside_development()
{ {
// Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new // Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new
// WebApplicationFactory<Program>()` — that keeps this host on the fixture's own per-class // WebApplicationFactory<Program>()` — that keeps this host on the fixture's own per-class
// isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's // isolated AppDb temp path (see TestWebApplicationFactory's doc comment; an earlier
// implementation note records the "table already exists" collision a bare factory hits // implementation note records the "table already exists" collision a bare factory hits
// by sharing the mutable static Db.ConnectionString instead). // by sharing the mutable static Db.ConnectionString instead).
using var staging = factory.WithWebHostBuilder(builder => builder using var staging = factory.WithWebHostBuilder(builder => builder
@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching // These stores read a single static Db.ConnectionString (there's no DI, matching
// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a // their earlier static-Dictionary shape — see Data/Db.cs). That's correct for a
// real single-instance process, but xUnit's default parallel-across-classes // real single-instance process, but xUnit's default parallel-across-classes
// execution would run multiple WebApplicationFactory hosts concurrently in this // execution would run multiple WebApplicationFactory hosts concurrently in this
// ONE test process, each overwriting that same static field with its own temp-file // ONE test process, each overwriting that same static field with its own temp-file
@@ -14,7 +14,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real /// A migration moved Applications/Documents/Briefs off in-memory dictionaries onto a real
/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path /// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path
/// would let concurrent test classes' WebApplicationFactory instances hit the same /// would let concurrent test classes' WebApplicationFactory instances hit the same
/// file at once — xUnit runs different test classes in parallel by default, and /// file at once — xUnit runs different test classes in parallel by default, and
@@ -29,7 +29,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
protected override void ConfigureWebHost(IWebHostBuilder builder) => builder protected override void ConfigureWebHost(IWebHostBuilder builder) => builder
.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}") .UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}")
// WP-52: a fixed shared secret so NotificatieTests can exercise the accept path — // A fixed shared secret so NotificatieTests can exercise the accept path —
// the appsettings.json default is "" (reject everything), which no test should rely on. // the appsettings.json default is "" (reject everything), which no test should rely on.
.UseSetting("Zgw:NotificatieAuthorization", "test-nrc-secret"); .UseSetting("Zgw:NotificatieAuthorization", "test-nrc-secret");
@@ -7,10 +7,10 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// Who may see what about an upload. RB-01/BIO-004: GET /uploads/{id}/content and /// Who may see what about an upload. BIO-004: GET /uploads/{id}/content and
/// /uploads/status used to take no HttpContext at all — a diploma or identity scan was /// /uploads/status used to take no HttpContext at all — a diploma or identity scan was
/// protected by GUID unguessability alone, while DELETE on the same resource was /// protected by GUID unguessability alone, while DELETE on the same resource was
/// owner-scoped. RB-04/BIO-005: the document audit trail recorded the raw owner BSN as /// owner-scoped. BIO-005: the document audit trail recorded the raw owner BSN as
/// its Actor, on a store whose own doc comment says it holds no PII. /// its Actor, on a store whose own doc comment says it holds no PII.
public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
@@ -5,8 +5,8 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// WP-64: the behandelportal's queue of aanvragen needing treatment, gated by the /// The behandelportal's queue of aanvragen needing treatment, gated by the
/// medewerker capability `CanBeoordelen` (WP-62) — not the admin role. /// medewerker capability `CanBeoordelen` — not the admin role.
public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory> public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
@@ -38,7 +38,7 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
var queue = (await res.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!; var queue = (await res.Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id); var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag); Assert.Equal("InBehandeling", mine.Status.Tag);
// RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. // BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto.
Assert.Equal("******782", mine.Owner); Assert.Equal("******782", mine.Owner);
} }
finally finally
@@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests; namespace BigRegister.Tests;
/// <summary> /// <summary>
/// WP-60's required verification: a ZGW failure mid-submit must not leave the two write sides /// The required verification: a ZGW failure mid-submit must not leave the two write sides
/// silently diverged — it's flagged (<see cref="Aanvraag.ZgwError"/>, an audit row) instead. /// silently diverged — it's flagged (<see cref="Aanvraag.ZgwError"/>, an audit row) instead.
/// Not an <see cref="IClassFixture{TFixture}"/> off <see cref="TestWebApplicationFactory"/>: that /// Not an <see cref="IClassFixture{TFixture}"/> off <see cref="TestWebApplicationFactory"/>: that
/// fixture hardcodes <c>Zgw:Enabled=false</c>, so this builds its own factory the same way /// fixture hardcodes <c>Zgw:Enabled=false</c>, so this builds its own factory the same way
@@ -111,7 +111,7 @@ public class ZgwDivergenceTests
Assert.Null(stored.ZgwError); Assert.Null(stored.ZgwError);
} }
/// RB-05/BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so /// BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so
/// the message it carries may not include the response body (OpenZaak echoes the request in /// the message it carries may not include the response body (OpenZaak echoes the request in
/// its errors) or the request's query string (ZGW filters travel there, and one of them is /// its errors) or the request's query string (ZGW filters travel there, and one of them is
/// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`). /// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`).
@@ -10,7 +10,7 @@ namespace BigRegister.Tests;
/// Factored out of OpenZaakZaakSourceTests once OpenZaakDocumentSourceTests needed the /// Factored out of OpenZaakZaakSourceTests once OpenZaakDocumentSourceTests needed the
/// identical stub. /// identical stub.
/// ///
/// WP-60: an optional <paramref name="status"/> callback lets a test inject a failing status /// An optional <paramref name="status"/> callback lets a test inject a failing status
/// for a given url on a given (0-based) attempt — e.g. "503 on the first call to /zaken, then /// for a given url on a given (0-based) attempt — e.g. "503 on the first call to /zaken, then
/// let it through" — to exercise ZgwHttpClient's retry without a live server. When it returns /// let it through" — to exercise ZgwHttpClient's retry without a live server. When it returns
/// a non-2xx code, <paramref name="respond"/> is not called for that attempt (so a test that /// a non-2xx code, <paramref name="respond"/> is not called for that attempt (so a test that
@@ -59,7 +59,7 @@ public class ZgwTokenProviderTests
[Fact] [Fact]
public void Mint_with_a_medewerker_caller_uses_the_medewerkerId_as_user_id() public void Mint_with_a_medewerker_caller_uses_the_medewerkerId_as_user_id()
{ {
// WP-62: SubjectId is what ZgwTokenProvider.Mint reads — a medewerker's is its // SubjectId is what ZgwTokenProvider.Mint reads — a medewerker's is its
// medewerkerId, not a BSN, and this is the only place that's directly observable. // medewerkerId, not a BSN, and this is the only place that's directly observable.
var caller = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter); var caller = new MedewerkerCaller("m.jansen", [MedewerkerRol.Behandelaar], "M. Jansen", PrincipalRole.Drafter);
var token = new ZgwTokenProvider(Options).Mint(caller); var token = new ZgwTokenProvider(Options).Mint(caller);
@@ -0,0 +1,149 @@
# RD-19 — Strip the `WP-`/`RB-` ticket references from `backend/`
Status: done
Source: PLAN.md Phase 2, item 4
## Why
The backend half of the sweep RD-18 did for the front end. 370 `WP-NN`/`RB-NN` references sit
in comments across 86 files under `backend/` — 70 `.cs` files plus the Dockerfile, four shell
scripts, four compose files, two `README.md`s and a handful of config files. `git blame` holds
the provenance and stays correct when the code moves; the comment names a closed ticket and
tells the reader nothing the sentence around it does not.
Strip the reference, keep the sentence. No behaviour changes.
## Read first
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs:8-13` — the golden-file test, and the
reason decision 2 exists.
- `public/letter.css` lines 1 and 4 — three references, mirrored byte for byte inside the
golden file.
- `backend/src/BigRegister.Api/appsettings.json:9` — the `_Zgw` key, a documentation string
rather than a comment.
- `RD-18`'s decisions block, for the sentence-quality rule this ticket repeats.
## Decisions (pre-made, don't relitigate)
1. **Nothing in `backend/` is exempt. Strip all 370.** Unlike the front end, no backend file
disables a check by naming the ticket that removes it — verified against
`pragma warning disable`, `Skip =`, `NoWarn` and `SuppressMessage`, none of which carries a
reference. Keep all 40 `ADR-000x` references; that count must not move.
2. **`public/letter.css` and `LetterHtml.golden.html` change together, or the build goes red.**
`LetterHtml.cs:161` finds `public/letter.css` at run time and **inlines it** into the
rendered letter. `LetterHtml.golden.html` is a snapshot of that output, so it embeds the
same CSS comment verbatim. Both hold the same three references (WP-24, WP-25 on line 1;
WP-25 on line 4).
- Edit `public/letter.css`.
- Apply the identical edit to the copy inside `LetterHtml.golden.html`.
- Editing either one alone fails `Renders_the_golden_brief`.
`public/letter.css` is the one file outside `backend/` that this ticket touches, exactly as
`scripts/gen-behaviour-spec.mjs` was for RD-18.
3. **No spec regeneration is needed, and that is a fact about C#, not an oversight.**
`scripts/gen-behaviour-spec.mjs:124` extracts backend `[Fact]`/`[Theory]` **method names**
into `behaviour-spec.mdx`. A C# method name cannot contain a hyphen, so no backend test name
can carry a `WP-NN`. Verified: zero `WP_NN`/`RB_NN` underscore variants exist either. Do not
run `gen:behaviour-spec`. If the drift check fires, you changed something this ticket did not
intend.
4. **Three kinds of reference live in a string, not a comment. All three are display-only and
all three get stripped:**
| Site | What it is |
| ---------------------------------------------------------------------------------------- | -------------------------------------------- |
| `appsettings.json:9`, the `_Zgw` key | a documentation string; no code binds `_Zgw` |
| `setup_configuration/data.yaml:15,29` + `.template` | the OpenZaak harness's `name:` / `label:` |
| `bootstrap-notificaties.sh:44`, `verify-notificatie.sh:32`, `bootstrap-catalogus.sh:171` | a `label=` value and two `echo` lines |
Verified: nothing in the repository greps for these labels, so renaming them breaks no
script. They are read by humans looking at an OpenZaak admin page.
5. **Keep the sentence readable, not merely shorter** — the same rule as RD-18. Several `.cs`
XML doc comments read "… (WP-53) is the acting citizen" or "— WP-73: a freshly submitted
aanvraag …", where the reference sits mid-sentence. Rewrite the clause. A stripped line must
not leave an empty `()`, a stranded "see", a dangling dash, or a doubled space.
6. **Leave `<paramref>`, `<see cref=…>` and every other XML doc tag intact.** They are compiled
references; breaking one is a build warning at best and a silent documentation hole at worst.
Only the ticket number inside the prose goes.
## Files
Everything under `backend/`, plus `public/letter.css`. About 87 files change.
As in RD-18, this ticket's acceptance commands address the directory rather than a file list,
because the sweep's contract is "no reference survives".
## Steps
1. Strip the references in `backend/`, working directory by directory so the diff stays
reviewable.
2. Apply decision 2: edit `public/letter.css`, then make the golden file's embedded copy match
it exactly.
3. Run `cd backend && dotnet test` on its own before the full gate. It is the fastest proof
that step 2 landed correctly.
4. `git add -A`, then run the acceptance commands.
5. Update this ticket's `Status:` to `done` and the README's RD-19 row to `done`.
6. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. `git grep -o … | wc -l` counts **occurrences**;
`git grep -c` counts lines and would give a different, wrong number.
```bash
git grep -oE "\b(WP|RB)-[0-9]+" -- backend public/letter.css | wc -l # is 373 -> MUST be 0
```
The ADR references survive, and the sweep leaves no damaged prose:
```bash
git grep -oE "ADR-[0-9]+" -- backend | wc -l # unchanged: 40
git grep -nE "^\s*(//|\*|#).*\s\(\)" -- backend public/letter.css | wc -l # unchanged: 0
git grep -nE "^\s*(//|\*|#).*[a-z] [a-z]" -- backend public/letter.css | wc -l # unchanged: 0
```
The golden file still matches the renderer (decision 2):
```bash
cd backend && dotnet test --filter FullyQualifiedName~LetterHtmlTests # exits 0
```
```bash
npm run ci # exits 0
```
## Verification
`npm run ci` is enough. `--full` is **not** required: this ticket touches no story, no `.mdx`,
and nothing under `libs/shared/src/ui/`. The README's Order table already leaves that column
blank for RD-19, and it is right this time.
If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale-database
trap, not your change. See this README's Troubleshooting section.
## Out of scope
- `apps/`, `libs/`, `docs/`, `.claude/`, `e2e/` — RD-18 did the first two; the rest keep their
references.
- `public/` beyond `letter.css`.
- Rewording a comment beyond what removing the reference requires.
- The `Case`/`Zaak` vocabulary rename (PLAN, "Deliberately out of scope").
## Risks
- **The golden file is the trap in this ticket.** Three references in `public/letter.css` are
mirrored inside `LetterHtml.golden.html`. Change one without the other and the golden test
fails. Change neither and the acceptance count cannot reach 0.
- **A reference inside a string is still a reference.** Decision 4 lists all three kinds. They
do not look like comments, so a comment-only regular expression misses them and the count
stops short of 0.
- **Do not touch `<see cref=…>` or `<paramref name=…>`** (decision 6).
- **`git grep`, never `grep -r`.** `grep -r` reaches `backend/bin`, `backend/obj` and the
gitignored SQLite files.
- **370 is measured today.** If your first count differs, re-measure before assuming the ticket
is stale.
+1 -1
View File
@@ -113,7 +113,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a | | RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a |
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done | | RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done |
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done | | RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo | | RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo | | RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo | | RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo | | RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo |
+2 -2
View File
@@ -1,7 +1,7 @@
/* letter.css the FEBE letter-rendering CONTRACT (WP-24/WP-25). /* letter.css the FEBE letter-rendering CONTRACT.
* *
* One stylesheet, two consumers: the FE letter canvas loads it via <link> * One stylesheet, two consumers: the FE letter canvas loads it via <link>
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25) * (index.html + Storybook preview-head), the backend HTML renderer
* inlines this same file. Its class-parity test is the fence against drift. * inlines this same file. Its class-parity test is the fence against drift.
* *
* Class vocabulary: .letter, .letter__letterhead, .letter__body, * Class vocabulary: .letter, .letter__letterhead, .letter__body,