Compare commits

...
76 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 c30d5ec5a5 docs(backlog): CD batch 6 complete, close the refactor backlog arc
All three tickets RB-31 to RB-33 merged, one commit per ticket. RB-31 found a
genuine ADR-0006 violation: two registratie-wizard tests asserted a state the
real reducer cannot produce. RB-32 closed ADR-0003's own predicted failure
mode with a permanent CI drift guard rather than a one-time fix. RB-33 chose
deletion over adoption for an unused test helper, since manufacturing a first
caller would have removed no real duplication.

This closes the CD implementation phase. All 33 code tickets and the four
gated ADR-fixes are merged; npm run ci is green after every merge in this
arc, each verified independently rather than trusting an agent's own report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:33:09 +02:00
eho 3441dd4c4e Merge RB-31 — replay real messages in 4 machine specs
ADR-C-010: intake, registratie-wizard, besluit and brief machine specs
hand-rolled a state literal, three of them hardcoding errors: {} by hand
instead of running real Msgs through the real reduce (ADR-0006 §2). intake
now shares the existing intake.testing.ts with the acceptance spec instead
of ignoring it; the other three each get a *.testing.ts one-liner.

Found real drift: two registratie-wizard tests asserted a cursor-2 state
reached before any diploma was chosen, which the real reducer cannot
produce (advancing past beroep requires a diploma already set). Replayed at
cursor 1 instead; submit() validates the whole draft regardless of cursor,
so the assertions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:27:16 +02:00
ehoandClaude Opus 5 dfc6c419f4 refactor(specs): replay real messages in 4 machine specs (RB-31)
Four machine specs built their starting state with a hand-rolled object
literal instead of replaying real Msgs through the real reduce, the
exact anti-pattern ADR-0006 section 2 forbids. Three of the four also
hardcoded errors: {}, a shape the reducer might never actually produce.

intake.machine.spec.ts now imports the existing givenIntake from
intake.testing.ts (previously used only by intake.acceptance.spec.ts).
Three new one-line *.testing.ts files export the same given(reduce,
initial) wrapper for registratie-wizard, besluit, and brief. Every old
literal helper (answering, invullen, editingWith, loaded) is replaced
by a message replay that reaches the same state.

Two tests in registratie-wizard.machine.spec.ts asserted a cursor value
the real reducer cannot reach (cursor 2 with no diploma chosen yet,
which requires a diploma to already be set). Both are re-pointed at the
reachable cursor-1 equivalent; submit() validates the whole draft
regardless of cursor, so no assertion changed. Recorded in
implementation/rb-31.md, not worked around.

No *.machine.ts reducer was touched. All four specs pass; npm run ci
is green (lint, typecheck, dep:check, format, tokens, seam, all four
test suites, both app builds, backend 293/293, api-client drift).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:26:12 +02:00
eho 1a6652b417 Merge RB-32 — add the missing language-switcher row to the CIBG gap register
ADR-C-008: 9 files carried a CIBG-GAP EXTENSION marker against 8 register
rows. language-switcher had a well-formed marker and no row. Adds the row,
plus an optional ~14-line drift guard in check-tokens.sh that diffs the
marker set against the register and fails naming any missing row, so this
drift cannot silently reoccur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:20:31 +02:00
ehoandClaude Opus 5 dd5fd66fb8 docs(backlog): mark RB-33 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:20:14 +02:00
ehoandClaude Opus 5 6cfba81a39 docs(shared): add the missing language-switcher row to the CIBG gap register (RB-32)
The register at libs/shared/docs/cibg-gaps.mdx had 8 rows for 9
CIBG-GAP EXTENSION markers in code. language-switcher carries a
well-formed marker with no matching row, exactly as ADR-C-008 and
adr-c-007.md's handoff note flag. Add the row from the component's
own marker comment.

Also add a small guard to check-tokens.sh (folded into check:tokens,
as ADR-C-008 suggests as an optional step): it diffs the marker set
in code against the register's rows and fails CI on drift. Verified
working with a scratch marker file before removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:31 +02:00
ehoandClaude Opus 5 0714b2af34 Merge RB-33 — delete unwrapOk, the unadopted test value-object helper
ADR-C-011: unwrapOk had zero consumers anywhere in apps/ or libs/ since it
shipped, and the one candidate call site (submit-change-request.spec.ts's
inline parse-and-throw guard) already satisfies ADR-0006 section 3's real
requirement, never a cast. Manufacturing a first caller to justify keeping
the helper would remove no real duplication. Deleted rather than adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:52 +02:00
ehoandClaude Opus 5 531817259e refactor(shared): delete unwrapOk, the unadopted test value-object helper (RB-33)
unwrapOk had zero consumers in apps/ or libs/ since ADR-0006 shipped it.
The one call site the finding named already satisfies the ADR's real
rule (call the real parser, never a cast) with an inline guard, so
adding a manufactured first caller was not the better fix. This commit
deletes the helper and its file, and updates the one doc sentence that
named it. The finding's call site is unchanged. See rb-33.md for the
full adopt-or-delete reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:21 +02:00
ehoandClaude Opus 5 03c6e09306 docs(backlog): CD batch 5 complete
All seven tickets RB-24 to RB-30 merged, one commit per ticket. Records the
actual wave split, since the backlog's own depends-on column missed that
RB-24 rewrites imports in two of RB-28's target files.

RB-24 expanded its own scope to fix a second, real boundary violation that
deleting its acceptance criterion exposed, reviewed and accepted. Two more
findings were shown stale or overstated, on top of the nine from earlier
batches. RB-26 and RB-27 both correctly declined part of their own ticket's
proposed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:39 +02:00
ehoandClaude Opus 5 c4a5d20202 docs(backlog): mark RB-27 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:53:00 +02:00
ehoandClaude Opus 5 a260af6120 Merge RB-27 — extract uploadOutcome from the XHR closure
TE-005: xhrUpload buried the 2xx-vs-not check, JSON.parse-with-fallback and
ProblemDetails mapping inside XHR listener bodies, unreachable without
stubbing the XHR global. uploadOutcome(status, responseText) is now a pure
function with no DOM and no XHR stub in its spec. Abort-vs-error
disambiguation stays where it is: it fires on a different event with no
status or responseText, so it cannot fit the extracted signature. The
optional currentScenario() move into KeepaliveTransport.send() was not
taken, since it would cross into upload-shell.service.ts, outside this
ticket's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:50 +02:00
ehoandClaude Opus 5 e63db509ef refactor(shared): extract uploadOutcome from the XHR load closure (RB-27)
UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the
actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the
body with a fallback, and ProblemDetails mapping via parseError. None of
it was reachable without stubbing the XHR global, so it had no spec
(TE-005; file LH 5/64, BRH 3/57).

Extract uploadOutcome(status, responseText): Result<string, {
documentId }>, a pure function next to genericError/parseError. It holds
the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails
mapping. The load listener is now a two-line dispatch into it.

Abort-vs-error disambiguation stays where it is: it decides whether a
response exists at all, before uploadOutcome would even run, and the
proposed signature has no field for "aborted". It is already a one-line
ternary with no DOM-only logic to extract.

Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub,
covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails
body, a non-2xx non-ProblemDetails body, and the 200/300 boundary.
Verified red by editing uploadOutcome down to one line (an Edit, not
git checkout): 4 of 5 new specs failed. Re-applied with a second Edit.
Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59.

Skip TE-005's optional half (moving the currentScenario() branch into
KeepaliveTransport.send()): it needs a second file, upload-shell.
service.ts, and this ticket's own scope fences it to upload.adapter.ts
and its spec. The dev simulator's behaviour is unchanged.

Mark RB-27 implemented in 99-backlog.md and add its implementation note,
including a batch 5 close-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:17 +02:00
eho 6372d452a4 Merge RB-28 — add BLOB_PRESENTER, unlock the blob-to-browser success paths
TE-006: StamdataStore.download(), BriefStore.previewLetter() and
OrgTemplateStore.proefbrief() each ended in raw DOM blob calls jsdom cannot
meaningfully execute, so their success paths were unassertable and
download()'s two-clause guard true-branch was permanently dark.
BLOB_PRESENTER mirrors the SESSION_PORT shape; all three commands go through
it. download()'s branch coverage goes from 40.5% to 67.6%, and
org-template.store.ts gets its first spec at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:39:09 +02:00
ehoandClaude Opus 5 ce952941bb refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).

Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.

Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.

Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:14 +02:00
eho a0e2985fb3 Merge RB-25 — add the UPLOAD_TRANSPORT injection token
TE-003: UploadShellService documented UploadTransport as the swap seam, then
bound the concrete, unexported KeepaliveTransport class directly, so a spec
could not fake it. UPLOAD_TRANSPORT copies the SESSION_PORT shape; the
default factory returns the same instance, so runtime behaviour is
unchanged. upload-shell.service.ts goes from 0% to 88.57% line coverage
across 16 new specs for upload(), cancel(), delete() and pollReturning().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:33:36 +02:00
ehoandClaude Opus 5 adad4513d0 refactor(shared): add UPLOAD_TRANSPORT injection token (RB-25)
UploadShellService injected the concrete KeepaliveTransport class instead
of a token. The class was not exported, so a spec could not fake it, and
could not provide against the UploadTransport interface either, since an
interface is not a DI token. The port existed only on paper.

Add UPLOAD_TRANSPORT, an InjectionToken with a default factory that
resolves the same KeepaliveTransport singleton, following the
SessionPort/SESSION_PORT shape. UploadShellService now injects the token.
Runtime behaviour is unchanged.

Add upload-shell.service.spec.ts: a recording fake transport plus a fake
UploadAdapter exercise upload(), delete(), cancel() and pollReturning(),
the four methods the missing seam left unreachable. Coverage for
upload-shell.service.ts goes from 0% to 88.6% line / 85% branch.

Mark RB-25 done in 99-backlog.md and add its implementation note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:31:34 +02:00
eho 3162c755d6 Merge RB-26 — extract planFileSelection from the upload controller
TE-004: createUploadController performed three inject() calls, an effect()
registration and a window listener before returning, so the real policy
buried inside it — deciding per file whether to reject or start an upload —
was reachable only through a TestBed. planFileSelection in upload.machine.ts
is now that decision as a pure function taking plain {name, type, size}
objects; the controller executes the plan and keeps the one impure step
(crypto.randomUUID()) it can't move. No change to the controller's public
surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:29:36 +02:00
ehoandClaude Opus 5 95bb77395e refactor(shared): move the accept/reject decision into planFileSelection (RB-26)
createUploadController required inject(), an effect(), and a window listener
before a test could reach it. The file-selection policy trapped behind that
cost now lives in a pure function, planFileSelection, in upload.machine.ts.

planFileSelection takes plain { name, type, size } objects, not File, and
decides per file whether to reject it or accept it, with no I/O. The
controller executes the plan: it dispatches a rejection as-is, and starts the
upload for an accepted file (the one step that needs crypto.randomUUID()).

A new spec covers the three outcomes: the 'multiple' batch rejection, a
rejectReason-based rejection, and the accept case, plus order in a mixed
batch. Verified red-then-green with a temporary stub, undone by a second edit.

No change to the controller's public surface or to the calling organism.
previewUrlFor (added by RB-24) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:28:30 +02:00
eho e304211715 Merge RB-30 — extract BriefStore's guards into Domain/Letters/BriefRules.cs
TE-008: five guard decisions in BriefStore (Save, Submit, Send, the shared
Approve/Reject review path) were pure functions of status tag, actor role and
entity completeness, but each sat inside a lock-held, DB-opening method, so a
spec could not exercise a decision without a booted host and a real SQLite
file. BriefRules.cs holds the five pure statics; BriefStore keeps its lock,
its Db.Create(), its static shape and every method signature. 29 new
free-running unit assertions in BriefRuleTests.cs; the existing host-booting
brief endpoint tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:08:00 +02:00
eho 80b792caa9 Merge RB-29 — resolve the body datum placeholder from at, not UtcNow
TE-007: Render already accepts the letter's instant and uses it correctly for
the letterhead, but the body's datum placeholder resolved through ResolveAuto,
which ignored at and read DateTimeOffset.UtcNow. Threaded at through
RenderParagraphs and RenderNode, both already in Render's call chain with at
in scope. Zero public API change, zero call-site change. The bug this
prevents: re-rendering an archive or back-dating a letter would otherwise make
the letterhead and body dates disagree within a single document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 08:01:39 +02:00
ehoandClaude Opus 5 ddd02f65bc fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it
for the letterhead date. The body's "datum" placeholder resolved
through ResolveAuto, which ignored that instant and read the wall
clock instead. This is not a shipped bug today, because every current
caller passes Now() at render time. It becomes one the moment Render
runs with a historical instant (an archive re-render, a back-dated
letter): the letterhead and the body would then disagree within one
document.

Thread the existing "at" parameter down through RenderParagraphs and
RenderNode into ResolveAuto's "datum" case. Render's own signature,
and every call site, stays unchanged.

Add two tests with a fixed historical "at": one pins the body's
rendered date to the expected Dutch string, the other asserts the
letterhead date and the body date agree. Both fail red against the
old code, showing today's date instead of the pinned one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:58 +02:00
ehoandClaude Opus 5 07bb6277c0 refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)
BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:00 +02:00
ehoandClaude Opus 5 693016445b Merge RB-24 — move upload/ into its proper layers, delete the carve-out
ADR-C-002: libs/shared/src/upload/ held a network adapter outside
infrastructure/ and the only Elm machine outside a domain/ folder, and the
dependency-cruiser rule was written around the violation rather than the
violation being fixed. The five files move to infrastructure/, domain/ and
application/, and the ^libs/shared/src/upload/ carve-out is gone.

Deleting the carve-out exposed a second, real violation that the old path had
hidden from the ui-not-infrastructure rule: three UI components injected
UploadAdapter for nothing but a one-line wrapper over its own exported pure
uploadContentUrl. They now read previewUrlFor from their application-layer
collaborator. dep:check passes for both apps with the clause removed, which is
the ticket's acceptance criterion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:41:54 +02:00
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00
ehoandClaude Opus 5 424ceb604b docs(backlog): CD batch 4 complete
All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two
incomplete tickets that the agents reported, RB-22's deliberate departure from
the runResult idiom, and how RB-19 was verified as a pure reorder.

Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13
agent-runs. A spend limit killed four agents mid-flight and a message resumed
each one from its own transcript, so no work was redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:21:01 +02:00
ehoandClaude Opus 5 dc096d98e2 Merge RB-19 — reorder Program.cs sections into reads then writes
CQ-006: the file declared direction as its organising principle, then switched
to feature grouping without saying so, and five sections interleaved reads and
writes. Each section now orders reads first, with the WP-65 sub-banner pair.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET /admin/cases,
129 lines closer. The org-template preview moves to the org-template section.

Pure reordering. Verified centrally: the sorted list of all 47 route strings is
identical before and after, and so is every (route, .Gate marker, wrapper called
in the handler) triple. The swagger.json and api-client.ts diffs are ordering
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:11 +02:00
ehoandClaude Opus 5 e270b8612f refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.

DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.

No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.

npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:16:05 +02:00
ehoandClaude Opus 5 edd20c06df docs(backlog): mark RB-23 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:09 +02:00
ehoandClaude Opus 5 9f3814d8b9 Merge RB-23 — make GET /brief a pure query that 404s when absent
CQ-007 contract half. GET /brief was the only backend endpoint where a GET
performed a persisted write, and the FE retries GETs automatically, so a
transient failure could enter the create path. BriefStore.GetOrCreate splits
into Get plus the existing ResetAndCreate. GET /brief/preview shared the same
call site and gets the same treatment. RB-22 already made the FE tolerate the
404, so the pair is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:42 +02:00
ehoandClaude Opus 5 d0fda08bcc fix(brief): make GET /brief a pure query, 404 when absent (RB-23)
GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.

Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.

RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.

Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:06 +02:00
eho 05dff974bf Merge RB-22 — tolerate a 404 on GET /brief with a one-shot reset
CQ-007 expand half. BriefStore.load() treats a 404 as 'no brief yet' and calls
the existing reset() command once. load()'s error channel becomes the
BriefLoadFailure union, because runResult folds the HTTP status away and the
store needs it. Today's backend never 404s, so the branch is a no-op until
RB-23 lands the contract half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:44:20 +02:00
ehoandClaude Opus 5 7a29f5facc feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22)
BriefStore.load() now treats a 404 from GET /brief as "no brief exists
yet" and calls the existing reset() command once, instead of showing
the generic load-failed error. BriefAdapter.load() gains a
BriefLoadFailure error channel (notFound | error) so the store can
tell a 404 apart from every other failure; every other adapter method
stays on runSubmit, unchanged.

The once-only bound is a field on the store, not a comment: a second
404 (from a later load() call) always falls through to the ordinary
error path, and the recovery path never calls load() again, so no
loop can form.

This is the expand half of CQ-007's split (04-cqrs-light.md). Today's
backend never 404s GET /brief, so the new branch is dead code until
RB-23 (the backend contract half) ships in a later merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:43:27 +02:00
eho 6c5c4cb249 Merge RB-20 — route cancel and delete through runSubmit, surface the error
CQ-002: ApplicationsStore.cancel and AdminCasesStore.delete reached the raw
ApiClient and swallowed the failure in a bare catch, so a failed cancel made the
row reappear with no message. Both now fold through runSubmit and expose
lastError, which the two pages render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:38 +02:00
eho 9666790d65 Merge RB-18 — key IdempotencyStore on caller plus idem key
BIO-018: the store was a process-global dictionary keyed on the client-supplied
Idempotency-Key alone, so one caller could replay another caller's key and
receive their cached response. The key is now scoped with the caller SubjectId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:12 +02:00
ehoandClaude Opus 5 7def4a7552 fix(ssp): route cancel/delete through runSubmit, surface the error (RB-20)
ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic
write back on failure but showed no message — a bare catch with no
Result and no error channel (CQ-002). Both now call runSubmit and set a
lastError signal on failure, mirroring createSubmitChangeRequest in the
same folder. Each page renders the error with the existing app-alert
atom, the same pattern brief.page.ts already uses for lastError.

Added a spec file for ApplicationsStore (none existed) and extended
AdminCasesStore's spec, each asserting the rollback AND the surfaced
error. Verified both new assertions fail without the fix (an Edit
undo/redo of the store method, not git checkout).

Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to
pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md
and recorded the change in implementation/rb-20.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:32:51 +02:00
ehoandClaude Opus 5 0adf831fb9 Merge RB-21 — extract the read half of createDraftSync into find-concept.ts
CQ-001: createDraftSync was the longest function in the repo and owned three
query paths next to its write path. findConcept and loadConcept are now free
functions that take the adapter, so they have a direct spec without TestBed.
The closure state (id, ensuring, resumeGate) stays where it was, because the
coupling is load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:20 +02:00
ehoandClaude Opus 5 25a5d415a5 docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009
The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.

ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.

ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.

ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.

ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.

Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.

Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:05 +02:00
ehoandClaude Opus 5 4631556e68 fix(backend): key IdempotencyStore on caller + idem key (RB-18)
IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.

Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:27:52 +02:00
ehoandClaude Opus 5 d518a1466c refactor(registratie): extract the read half of createDraftSync (RB-21)
createDraftSync mixed a read path (findConcept, load, the read half of
resume) with its write path (ensureId, flush, submit, reset) in one
187-line function -- CQ-001's finding. Move findConcept and loadConcept
into a new application/find-concept.ts as free functions that take the
adapter, so they get a direct spec with no Angular TestBed.

createDraftSync keeps the closure state (id, ensuring, resumeGate) and
the whole write path unchanged -- this is a move, not a redesign. The
resumeGate coupling that lets the write path wait for the read path
stays exactly where it was.

createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is
unchanged -- it never called resume()/load() directly, and its 409
recovery test for submit() still exercises the extracted findConcept
through ensureId's catch branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:23:13 +02:00
ehoandClaude Opus 5 7fbac8fca5 docs: write English prose in Simplified Technical English
Adds a Conventions rule for Simplified Technical English (ASD-STE100). It
covers documentation, code comments, commit messages, ADRs, and the backlog
notes. STE is a controlled language. It makes text easy to read for people
who do not have English as a first language, and easy to translate. The
readers of this project are mostly non-native English readers.

The rule states that STE governs form, not content. Split a long sentence.
Never remove a caveat, a measurement, or a precise term to make text shorter.

The rule does not apply to Dutch identifiers, $localize copy, quoted output,
or existing documents that you are not already editing. It therefore does not
change the Naming convention above it, which keeps domain contexts in Dutch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:03:37 +02:00
ehoandClaude Opus 5 bc5b2c4b2d docs(backlog): CD batch 3 complete
All six merged, gate green (14 steps, backend 260/260). Records the three
tickets that could not be built as written — RB-12's wrapper/public binary
does not fit the route table, RB-14's command exits 0 on a High advisory, and
RB-15 needed a third environment name because RB-09 makes Production fail to
boot — plus RB-13's measured duplication drop (168 -> 32 lines per side).

Adds a section on dispatching implementation agents. Four of six agent-runs
were handed a worktree branched from a stale ancestor; batch 3 was three for
three. That, the background-task parking, and the git-checkout-destroys-work
trap are all cheap to prevent in the prompt and expensive to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:01:41 +02:00
eho 2a28db4aac Merge RB-12 + RB-15 + RB-16 — route-table authz gate, Swagger dev-only, peildatum 400
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:41 +02:00
eho ab0ec62f6a Merge RB-13 — land Session -> Principal, add MedewerkerAdapter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:34 +02:00
ehoandClaude Opus 5 f19185ed81 refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.

Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.

Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.

Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:54:26 +02:00
ehoandClaude Opus 5 b617d2f09a docs: regenerate behaviour-spec for RB-12/RB-15/RB-16
New backend test classes (RouteInventoryTests, SwaggerGateTests) plus
one added case to StamdataEndpointTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:52 +02:00
ehoandClaude Opus 5 2627799284 fix(backend): 400 instead of 500 on an unparseable peildatum (RB-16)
BIO-019: GET /stamdata/{table}?peildatum= called DateOnly.Parse
directly, which throws FormatException on anything unparseable — an
unhandled 500 (leaking exception detail in Development) instead of
the 400-with-problem-details every other bad-input check in this file
returns. §3c named backend/Stamdata's 71.7% branch coverage (BL-005)
as the weak spot this bug lived in.

Switched to DateOnly.TryParse; an unparseable value now returns
Results.Problem(detail: ..., statusCode: 400), matching the shape the
upload/change-request endpoints already use. Endpoint doc gained
.ProducesProblem(400), so the OpenAPI doc + generated client were
regenerated and committed in this same diff (RB-09's note records a
prior incident where a response-shape change shipped without this and
the drift went unnoticed).

No FE change needed: libs/beheer's stamdata adapter already funnels
every call through runSubmit, which folds any thrown ApiException
(now including this 400) into a generic Result error — ADR-0001's
"the FE renders the decision" already covers "the server rejected
this input".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:29 +02:00
ehoandClaude Opus 5 a93218e8ac fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.

Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.

RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.

New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:40:23 +02:00
ehoandClaude Opus 5 ee0d449510 test(backend): assert every route is authz-gated (RB-12)
BL-006: the backend has zero automated architecture enforcement.
BIO-016 names the concrete consequence for authorization — nothing
asserted the *set* of gated endpoints, so BIO-003's X-Admin gate
(outside Authz) and BIO-004's two ungated endpoints were caught only
by a human reading Program.cs, not by CI.

Adds RouteInventoryTests: walks the real app's EndpointDataSource and
asserts every mapped route either carries a .Gate("XAdmin") metadata
marker (added at the 16 call sites that already call one of the five
admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/
FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved
it's hard to fool by adding a throwaway unguarded route, watching the
test go red, and reverting.

The allow-list is not "public routes" as the ticket's shorthand put
it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/
ctx.Caller()) endpoints, not public ones, and labelling them public
would misrepresent the exact property BIO-004 was about. Each entry
instead carries its own reason. Implementation note has the full
route-by-route breakdown and judgement calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:36:30 +02:00
ehoandClaude Opus 5 1c5442d797 Merge RB-17 — split runResult out of runSubmit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:34:16 +02:00
ehoandClaude Opus 5 80de261299 refactor(shared): split runResult out of runSubmit (RB-17)
runSubmit did two things at once: fold a call into a Result, and mint
an Idempotency-Key for it. Five call sites are reads and had no
business minting one — brief.adapter.ts:load, org-template.adapter.ts
:list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's
own docstring already said "Both endpoints are reads … There is no
write method" while both called runSubmit; that mismatch is the
sharpest evidence, and the reason the baseline's original "~13
mutations" count (derived from the helper's name, not the code) was
wrong by five in one direction.

Split submit.ts in place: runResult is the try/catch + problemDetail
fold with no mint; runSubmit is runResult wrapping
withIdempotencyKey. Zero behaviour change for the 8 real mutations
(brief save/submit/approve/reject/send/reset, org-template
save/publish/rollback) — same fold, same mint, same timing. The five
reads now run the fold with no pendingIdempotencyKey touched.

submit.spec.ts asserts the split behaviourally via
currentIdempotencyKey() (two reads inside the same call agree only
when a key was minted and reused) rather than mocking a relative
import, matching this repo's existing vitest convention. Verified red
without the fix by temporarily reintroducing the mint into runResult.

ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and
FeatureFlagStore.set are out of scope and untouched — the latter
already calls runSubmit correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:31:13 +02:00
ehoandClaude Opus 5 adfaa32a42 ci: gate on known advisories in the .NET dependency tree (RB-14)
npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent
existed for the backend, so the entire .NET dependency tree — direct and
transitive — was unscanned (BIO-016 lists it first under "Absent").

The ticket's literal wording would not have worked. `dotnet list package
--vulnerable` is a reporting command: it prints the advisory table and exits
0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 —
severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list
package --vulnerable` would have added a line that reads like coverage in a
compliance review and enforces nothing, which is worse than leaving the gap
visible.

scripts/dotnet-audit.sh runs the scan and matches "has the following
vulnerable packages" — the exact sentence dotnet prints per project on a hit.
One script, two callers (ci.yml and ci-local.sh), so the workflow and the
local gate cannot drift apart.

No severity threshold and no suppression list: picking either before a real
advisory forces the question would be guessing at a policy nobody needs yet.
Secret scanning, BIO-016's other named absence, stays on the checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:24:16 +02:00
ehoandClaude Opus 5 988612cd7e docs(backlog): CD batch 2 complete
All five tickets merged and green on the fixed gate (13/13 steps, exit 0).
RB-07 unblocks ADR-C-009; RB-09 unblocks RB-13 in batch 3.

Adds a "Gate integrity" section recording that every earlier "ci green" in
this file predates the ci-local.sh errexit fix and is weaker than it reads.
Batch 1 has not been re-verified under the honest gate, and the note says so
rather than leaving a reader to assume it was.

Also records what batch 2 leaves open: RB-01's residual is NOT solved by
RB-09 (the upload-content link is still a plain browser navigation with no
credential), and a non-Development non-Production environment fails fast at
GetRequiredService rather than at RB-09's deliberate throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:37:20 +02:00
ehoandClaude Opus 5 6bdfa35abb build: stop ci-local.sh swallowing the first half of every paired step
Under `set -e` bash exempts every command of an AND-OR list except the last,
so `npm run gen:api && git diff --exit-code ...` silently swallowed a CRASH
in gen:api: the diff never ran and the script sailed on to print "local CI
passed". Verified directly — `bash -c 'set -e; false && true; echo hi'`
prints hi and exits 0, while `false; true` exits 1.

This was not hypothetical. It hid a real gen:api crash introduced by RB-09
(dotnet swagger's design-time host defaults to Production, which that ticket
made throw at startup). .github/workflows/ci.yml would have caught it, since
it runs each step as its own `- run:` — so the local gate was strictly weaker
than the remote one, which is the opposite of its stated purpose.

Six steps were affected. The worst was `ng build ssp --localize && ng build
behandelportal --localize`: a missing English translation in ssp — the exact
thing the second-locale gate exists to catch — could not fail the run.

The one `( cd backend && ... )` step is safe as-is and left alone: a subshell
propagates its own non-zero status, so errexit sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:31:50 +02:00
eho 2fa96c300c Merge RB-08 + RB-09 — CasesAdmin on the admin upload delete; no-identity representable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:30:41 +02:00
ehoandClaude Opus 5 c6bc6dd4c3 docs(backlog): RB-11 done
Also records what RB-11 turned up: BIO-012 was factually wrong that the
proefbrief error mapping was already a separate function (it was inlined in a
try/catch), and the step-up literal is still a literal, moved one layer up to
the only caller rather than eliminated — BIO-006(c) stays a production gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:29:50 +02:00
ehoandClaude Opus 5 d089151dbd docs: record the gen:api regression found while verifying RB-09
Documents the dotnet swagger tofile crash discovered by actually
running the affected command (not just trusting ci-local.sh's local
"passed" line, which turned out to mask this exact failure via a
set -e && short-circuit gotcha), its root cause, and the two follow-up
fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:22:26 +02:00
eho c336328cff Merge RB-11 — keep the dev hatches out of production builds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:21:51 +02:00
ehoandClaude Opus 5 436e18421b fix(tooling): keep gen:api working under RB-09's Development-only stub
RB-09 registers StubIdentityProvider only under IsDevelopment() and
throws for Production. `dotnet swagger tofile` (npm run gen:api) loads
the same Program.cs through .NET's design-time HostFactoryResolver,
which executes the app's startup code (including the unconditional
app.Services.GetRequiredService<IIdentityProvider>() the identity
middleware already relied on) without ever setting
ASPNETCORE_ENVIRONMENT - so it now defaults to Production and crashes
(dotnet swagger tofile exited 134), breaking `npm run gen:api`
entirely, including the "api-client drift" job in
.github/workflows/ci.yml (a separate `- run:` step there, so this
would fail real CI even though ci-local.sh's `cmd1 && cmd2` step
shape happens to swallow a cmd1 failure under `set -e` and reports
"passed" - a separate, pre-existing script fragility, not touched
here).

Real usage is unaffected: dotnet run already sets
ASPNETCORE_ENVIRONMENT=Development via launchSettings.json, and
docker-compose.yml/docker-compose.prod.yml already set
Development/Production explicitly - gen:api's bare CLI invocation was
the one place with no environment variable at all. Set it to
Development inline, the same value launchSettings.json already uses
for the real app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:34 +02:00
ehoandClaude Opus 5 1fad740606 fix(api): regenerate client for RB-08's 403 response shape
RB-08 changed DELETE /admin/uploads/{documentId}'s 403 mapping from
.Produces to .ProducesProblem, matching CasesAdmin's actual
Results.Problem() response - but the generated OpenAPI doc and typed
client were never regenerated alongside it, so CI's api-client-drift
check (npm run gen:api && git diff --exit-code) was left red. No
frontend consumes this admin-only endpoint (confirmed by grep), so
this is a pure regeneration with no consumer impact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:26 +02:00
ehoandClaude Opus 5 772c47ea43 fix(brief): keep the dev hatches out of production builds (RB-11)
BIO-012: roleInterceptor/subjectInterceptor are correctly registered
only under isDevMode(), but three hand-written fetch adapters
(reveal-bignummer, letter-preview, org-template's proefbrief) bypass
HttpClient and set X-Role/X-Subject themselves with no guard. The
readers underneath, role.ts and subject.ts, were ungated too: they
read ?role=/?subject= and wrote it into sessionStorage on any
navigation, in any build -- for ?subject= that value is a BSN, which
is exactly what SessionStore's G1 comment promises never happens.

Gate both layers: currentRole()/currentSubject() return their safe
default immediately outside isDevMode() (no query-param read, no
sessionStorage write), and the three adapters additionally wrap their
headers in isDevMode() so a production request carries neither header
at all, matching what an HttpClient request already does once the
interceptors aren't registered.

TE-002: reveal-bignummer's response-shape validation was a "Trust
boundary" a spec could only reach by stubbing globalThis.fetch.
Exported it as parseRevealed(body), matching the other 30 parse*
boundaries in the repo. Same treatment for letter-preview's
errorMessage and org-template's proefbrief error mapping (extracted
from an inline try/catch into a named, exported function first, since
it wasn't already separate).

BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally,
so the backend's step-up precondition constrained nothing. reveal()
now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable
only after the UI's confirm() gesture -- is the one that supplies it,
so the literal no longer lives in the transport adapter.

BIO-006(b): documented in roles-and-access.md that drafter is also
the backend's fallback identity (StubIdentityProvider's catch-all
arm), not just the dev switcher's initial choice -- so the
least-privilege consequence of it also being the only role that may
reveal a BSN is visible.

Doc correction, same diff: roles-and-access.md's "wired only under
isDevMode()" claim was false for the three hand-written fetch paths;
it now says where the gate lives (interceptor registration and the
reader functions) so it doesn't go stale the same way again.
CLAUDE.md's dev-only claims needed no correction -- they already
noted these three calls bypass the interceptor.

Every fix has a test confirmed red by temporarily reverting the
source change and rerunning the suite before restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:20:47 +02:00
ehoandClaude Opus 5 b5432d2c63 docs(backlog): RB-07 and RB-10 done, batch 2 in progress
RB-07 unblocks signing ADR-C-009 (its clause 4, "writes are
admin-capability-gated and audited", now holds) and closes CQ-004's
outstanding half. RB-10 landed parseStoredSession twice, once per app,
deliberately — recorded so a later reader does not file it as duplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:15:43 +02:00
ehoandClaude Opus 5 4ac13f6cb5 docs: regenerate behaviour spec (RB-07 drift, RB-08, RB-09)
`npm run gen:behaviour-spec`'s drift check (part of `npm run ci`)
caught two things: RB-07 had already left this generated doc stale
(three of its new AuthzAuditTests cases were never picked up), and
RB-08/RB-09 added more test names since. Regenerated so the doc
matches the suite it claims to mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:51 +02:00
ehoandClaude Opus 5 9bff19a3e6 build: keep agent worktrees out of prettier and git
Running implementation agents with worktree isolation puts full checkouts of
this repo under .claude/worktrees/. `prettier --check .` walks into them, so
`npm run ci` went red on 70 files that belong to another checkout — including
the vendored CIBG design system, which the top-level ignore already excludes.

Ignored in both .prettierignore and .gitignore; the latter so a worktree can
never be committed into the repo it is a checkout of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:18 +02:00
eho 5118b0da95 Merge RB-10 — extract and spec the stored-session parse boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:08:48 +02:00
ehoandClaude Opus 5 3545023af8 docs: regenerate the behaviour spec for RB-07's new tests
The RB-07 commit added three AuthzAuditTests cases and did not regenerate
libs/shared/docs/behaviour-spec.mdx, so the "behaviour spec drift" CI step
was left red on that branch. My mistake: I committed RB-07 and answered a
question before running the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:27 +02:00
ehoandClaude Opus 5 de349e702e test(auth): extract and spec the stored-session parse boundary (RB-10)
SessionStore.restore() — identical in both apps — read localStorage itself
and did the parse plus shape validation in the same module-private function,
invoked from a field initializer, so the storage read happened the instant
the singleton was constructed and no spec could feed it a raw string. The
logic it guards is a trust boundary, not incidental validation: the comment
above it names G1 (never persist the BSN) and G2 (validate the shape before
trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters.
ssp/auth and bhp/auth were jointly the worst-covered frontend modules.

parseStoredSession(raw) moves into each app's auth/domain/session.ts, which
is pure TS and already had a spec, so no new scaffolding was needed;
restore() collapses to one line. Four cases: absent, non-JSON, wrong shape,
and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn
'', which makes the G1 guarantee executable rather than merely commented.
Verified red without the fix.

Landed twice, once per app, deliberately. TE-001 and BL-002 both say an
extract-to-shared here would contradict ADR-0002, which models the two
actors as different Principal variants and expects the two auth contexts to
diverge; RB-13 is what differentiates them.

Also specs redactProfile (BIO-017's second half) — a pure exported
PII-redaction function that had none.

behaviour-spec.mdx is regenerated, which also picks up the test names RB-07
added; that commit should have carried them and did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:06 +02:00
ehoandClaude Opus 5 8b8b522052 fix(auth): make no-identity representable; stub dev-only (RB-09)
IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so
the interface could not express "no identity" - StubIdentityProvider
was forced to invent one for any request carrying no credential at
all. Consequence: a production behandelportal build sends no
X-Medewerker header (medewerkerInterceptor is dev-only), so it used
to authenticate as the seeded citizen, role drafter - failing closed
on backoffice capabilities but open on every citizen-scoped endpoint,
including CanRevealBigNummer.

Resolve now returns CallerIdentity?. StubIdentityProvider keeps a
non-nullable return type (a valid narrower override) since it never
itself has "no identity" to report - it is registered only under
IsDevelopment() now. Production registers nothing and throws an
InvalidOperationException immediately during startup instead: there
is no real DigiD/employee-SSO provider in this POC yet, so a
misconfigured Production deploy must fail before serving a single
request, not resolve one per request. The identity-resolution
middleware turns a null resolution into a 401 rather than passing it
downstream.

Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all
and ProductionIdentityProviderTests, which builds its own
WebApplicationFactory<Program> with UseEnvironment("Production") and
asserts startup throws. Verified both new tests fail red against the
pre-fix code.

RB-01's residual (GET /uploads/{id}/content reached via plain browser
navigation, no identity header) is confirmed unchanged in Development
and its Production consequence is written up in
implementation/rb-09.md for whoever lands the real identity provider -
no signed-URL/cookie scheme was designed here, per scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:06:49 +02:00
ehoandClaude Opus 5 494cee9d08 fix(uploads): route the admin delete through CasesAdmin (RB-08)
DELETE /admin/uploads/{documentId} was gated by a standalone
`X-Admin: true` header check (`IsAdmin`), outside the `Authz` module
entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/
`FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no
AuthzAuditStore row, so a destructive cross-owner document delete never
appeared on /beheer/audit. A repo-wide grep confirmed the only sender of
X-Admin was the backend test itself — no frontend or e2e path depends on
it — so the gate was safe to delete outright.

Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same
wrapper the other admin-cases endpoints use. RB-07 already moved
AuditAuthz onto every *Admin wrapper's allow path, so this gets the
missing audit row for free with no second AuditAuthz call. Deleted the
now-unused IsAdmin function and updated the two comments that referenced
the old X-Admin seam.

Updated EndpointTests.cs's Admin_delete_requires_admin_role to send
X-Role: admin instead of X-Admin: true, and added
AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the
cases:manage/allow row count increases by exactly one (a plain
Contains would already be satisfied by this test class's other
cases:manage calls). Verified both tests fail red against the
pre-fix gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:55:41 +02:00
ehoandClaude Opus 5 e89525eef6 feat(audit): record the allow path, not just the denial (RB-07)
All five authorization gates audited only their deny branch, so /beheer/audit
could answer "who was turned away" but never "who changed this" — for a
register whose integrity is the product, the wrong half. Nothing recorded the
flag toggle, either org-template write, the admin case or upload delete, the
three brief transitions, or the besluit; the comment claiming endpoints log
their own effect held for two of the eight.

Each gate now computes the decision once, audits it, and then acts. The row
is written by the gate rather than the endpoint, so a new admin endpoint
cannot be added that forgets to audit itself. Same reasoning for the brief:
every transition already funnelled through LogBrief for its log line, so the
audit row goes there too — submit/approve/reject/send in one place, with the
transition's own outcome as the decision, so a 403 or 409 is as visible as a
success.

FlagsAdmin gained a per-call resource, the one deviation from BIO-007's
minimal remediation: the toggle endpoint writes no log line of its own, so a
constant "feature-flags" row would say a flag changed without saying which.
It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep
coarse refs because those endpoints do log the specific object.

The besluit gets a second row: the gate records that a behandelaar was
allowed to act, aanvraag:besluit records what they decided.

Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads
now write rows. That is what auditing the allow path means; it is also what
would make retention on AuthzAuditStore necessary later.

Closes CQ-004's outstanding half and unblocks signing ADR-C-009.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:12:22 +02:00
ehoandClaude Opus 5 d682f91b54 docs(backlog): lift the HALT, record CD batch 1 as complete
The operator approved 99-backlog.md, so Phase 3 started. RB-01..RB-06 are
marked done in the table, _status.md gains a Phase 3 section tracking all six
batches, and the halt notice is replaced by what batch 1 leaves for later
batches to pick up:

- RB-01's residual is RB-09's problem — the document-content endpoint is
  reached by a plain browser navigation with no identity header, so it
  resolves to the seeded citizen and works only because one citizen owns
  every document in the POC. That is BIO-002.
- Pii.MaskTail now lives in Domain/People/Pii.cs; RB-11 should use it rather
  than hand-rolling a second masker.
- RB-06 additionally deleted SubmissionRules.RejectRegistratie, which its row
  did not ask for.

Also records the standing OpenZaakIntegrationTests failure, which needs a
live container and is unrelated to any of these tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:06:10 +02:00
ehoandClaude Opus 5 0298ecc506 fix(uploads): delete the dead POST /registrations (RB-06)
POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.

Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.

Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.

swagger.json, the generated client and the behaviour spec regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:04:03 +02:00
ehoandClaude Opus 5 5187bfa19a fix(zgw): keep the BSN out of the recorded ZGW failure message (RB-05)
ZgwHttpClient interpolated the full request uri and up to 500 characters of
the response body into its failure message. That message is persisted as
Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry
a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list
filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and
OpenZaak echoes the offending request in its error bodies, so a rejected POST
/rollen comes back holding the owner BSN it was sent.

All three interpolation sites now use Redact(url) — the path without its
query — and the body snippet is replaced by the reason phrase. Status plus
path still routes a failure to the right endpoint; the lost detail already
has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is
opt-in, dev-only and not persisted.

The new test fails the one call in the fixture whose url carries a query
string and asserts the persisted ZgwError has neither the body snippet nor a
"?", while keeping the path and the 503. Verified red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:57:09 +02:00
ehoandClaude Opus 5 fbd27ed641 fix(privacy): mask the BSN recorded as the document audit Actor (RB-04)
DocumentStore wrote one audit row per upload and per user delete carrying the
acting citizen's raw BSN as AuditEntry.Actor, persisted to SQLite — on a
store whose own doc comment says it holds metadata only, never file content
"or other PII". Same shape as RB-02, in a second store.

Masked at the two citizen call sites rather than inside Audit, because the
third actor is the literal "admin" and MaskTail("admin", 3) is "**min";
masking centrally would mean guessing which actors are BSNs and which are
role names. Audit's doc comment now states that actors arrive redacted.

StoredDocument.Owner is untouched: it is the authorization key that
DeleteOwned, ForeignIds and RB-01's content check all compare against, so the
BSN stays where it is load-bearing and leaves the trail where it was only
decoration. No endpoint exposes AuditLog, so no response shape changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:54:07 +02:00
ehoandClaude Opus 5 487818e67a fix(privacy): mask the owner BSN on the cross-owner case lists (RB-03)
Mappers.ToAdminSummaryDto set Owner to the raw BSN. Both consumers are
cross-owner lists read by someone who is not the subject — GET /admin/cases
and GET /werkvoorraad — while GET /beoordeling/{id}, the detail view of the
same data, already masked it. The detail screen showed ******782 and the list
one click earlier showed the whole thing.

Masked in the mapper rather than at each endpoint, so a third cross-owner
list cannot be added that forgets to.

MaskTail moves out of Program.cs into Domain/People/Pii.cs: it now has
callers in Contracts, Program.cs and (once RB-04 lands) Data, and a second
hand-rolled copy is how one of them drifts into leaking. Documented as
idempotent, which is what lets /beoordeling/{id} keep its own call —
IZaakSource has a second implementation whose Owner is mapped from the
OpenZaak zaak identificatie, so that endpoint should not depend on which
source answered.

No frontend change: all three consumers display the value, and the parse
boundaries only require a non-empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:52:29 +02:00
ehoandClaude Opus 5 6ffd3643b1 fix(audit): stop writing a BSN into the authz audit Resource (RB-02)
Program.cs built the BIG-nummer reveal's audit resource ref as
"brief/" + ctx.Zorgverlener().Bsn. AuditAuthz persists that to the
AuthzAudit.Resource column in SQLite and /admin/audit renders it, so a BSN
reached durable storage and a UI on the one trail four documents describe as
data-minimised and PII-free — on the endpoint whose own comment promises the
audit carries no PII.

The ref is now "brief". Nothing is lost: BriefStore keys one brief per owner,
so the id named what the row's acting principal already implies.

The existing guard, The_audit_schema_carries_no_pii, asserts on column names,
so a BSN inside a column called Resource could never fail it. Added
No_audit_row_carries_a_subjects_bsn, which drives a denied reveal as a
non-default subject and scans every string field of every row for that BSN
and for DemoOwner — asserting on the two BSNs actually in play rather than a
\d{9} shape, since a hex correlation id can hold nine digits by chance.
Verified it goes red when only the Program.cs line is reverted.

AuditEntry.Actor on document audit rows holds a raw BSN too; that is a
different store and stays with RB-04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:49:57 +02:00
ehoandClaude Opus 5 a2e935d1d8 fix(uploads): authorize the document-content and status endpoints (RB-01)
GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:48:04 +02:00
176 changed files with 8622 additions and 1285 deletions
+2 -2
View File
@@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
from: { pathNot: '/infrastructure/' },
to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
+5
View File
@@ -202,6 +202,11 @@ jobs:
# run manually against backend/openzaak/ (see its README), never in CI.
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
if: needs.changes.outputs.backend == 'true'
# RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency
# tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate,
# because `dotnet list package --vulnerable` exits 0 even on a High advisory.
- run: ./scripts/dotnet-audit.sh
if: needs.changes.outputs.backend == 'true'
e2e:
needs: changes
+4
View File
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
backend/openzaak/setup_configuration/data.prod.yaml
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
# this repo nested inside it; never commit one.
.claude/worktrees/
+5
View File
@@ -4,6 +4,11 @@ storybook-static*/
coverage/
.angular/
# Agent git worktrees — full checkouts of this repo nested inside it, so an
# unignored `prettier --check .` walks into every one of them (and reports the
# vendored CIBG files that the top-level ignore already excludes).
.claude/worktrees/
# Lockfile
package-lock.json
+29 -6
View File
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
small hand-rolled surface built from the token bridge; see ADR-0003.)
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
vendored `.feedback feedback-*` classes.)
### 3. State: make illegal states unrepresentable
@@ -175,8 +177,14 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
The generated client
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
it too loosely (the four survivors are all the latter — the generator emits every property as
optional and flattens unions); such a file must still import nothing. Either way a hand-written
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules live **only** on the server, with no FE mirror to drift from it — the FE may
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
@@ -185,8 +193,12 @@ never reimplements the _algorithm_.
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
code, an unknown key fails closed, the value is operational rather than a shared business rule,
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
### 5. Testing
@@ -214,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on.
- **English prose uses Simplified Technical English (STE).** This covers documentation,
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
per step.
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
measurement, or a precise term to make it shorter.
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
existing documents you are not already editing.
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
first-party `$localize` (no third-party i18n lib), with a stable custom id
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
@@ -1,58 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
without stubbing `localStorage`; this just supplies the raw value. */
function restore(): Principal | null {
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
* in an httpOnly cookie/token, not web storage.
* Holds the current medewerker principal for the whole backoffice app. One
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* (the guard, the header). Persisted to localStorage — a refresh or the
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* which is safe to do verbatim here: a medewerker principal carries no BSN or
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
* comment explains why *that* app strips a field before writing. A real
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore());
private medewerker = inject(MedewerkerAdapter);
private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
const p = this._session();
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
/** Effectful command: authenticate via the SSO stand-in, then store the
resulting principal. No credential to pass in, and nothing that can fail
today — see `MedewerkerAdapter`. */
async login(): Promise<Principal> {
const p = await this.medewerker.authenticate();
this._session.set(p);
return p;
}
logout() {
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
const principal: Principal = {
kind: 'medewerker',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: ['behandelaar'],
};
describe('isAuthenticated', () => {
it('narrows a present principal to Principal', () => {
expect(isAuthenticated(principal)).toBe(true);
});
it('reports no principal as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredPrincipal', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredPrincipal(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredPrincipal('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
).toBeNull();
});
it('returns null when kind is not medewerker', () => {
expect(
parseStoredPrincipal(
JSON.stringify({
kind: 'zorgverlener',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: [],
}),
),
).toBeNull();
});
it('returns null when rollen holds an unrecognized token', () => {
expect(
parseStoredPrincipal(
JSON.stringify({
kind: 'medewerker',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: ['geen'],
}),
),
).toBeNull();
});
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
const restored = parseStoredPrincipal(JSON.stringify(principal));
expect(restored).toEqual(principal);
});
});
describe('parseRollen', () => {
it('parses a single recognized rol', () => {
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
});
it('is case-insensitive and trims whitespace', () => {
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
});
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
expect(parseRollen('geen')).toEqual([]);
});
it('returns an empty list for an empty string', () => {
expect(parseRollen('')).toEqual([]);
});
});
@@ -0,0 +1,71 @@
/**
* Who is logged in. Framework-free domain type.
*
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
* unrepresentable-by-construction distinction as the whole point of the union.
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
* already stamps onto every backend request — it does not itself grant anything;
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
* on what this principal may do (ADR-0001, ADR-0002 §3).
*/
export type Rol = 'behandelaar';
const ROLLEN: readonly Rol[] = ['behandelaar'];
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
export interface Principal {
readonly kind: 'medewerker';
readonly medewerkerId: string;
readonly naam: string;
readonly rollen: readonly Rol[];
}
export function isAuthenticated(p: Principal | null): p is Principal {
return p !== null;
}
/**
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
* comma-separated, case-insensitive, unrecognized tokens dropped — so
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
* stay a thin wire-up instead of holding logic of its own.
*/
export function parseRollen(raw: string): Rol[] {
return raw
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(isRol);
}
/**
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the shape
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
* — a medewerker carries no national identifier — so a well-shaped record is
* restored as-is rather than reconstructed field-by-field.
*/
export function parseStoredPrincipal(raw: string | null): Principal | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Principal>;
return parsed?.kind === 'medewerker' &&
typeof parsed.medewerkerId === 'string' &&
typeof parsed.naam === 'string' &&
Array.isArray(parsed.rollen) &&
parsed.rollen.every(isRol)
? {
kind: 'medewerker',
medewerkerId: parsed.medewerkerId,
naam: parsed.naam,
rollen: parsed.rollen,
}
: null;
} catch {
return null;
}
}
@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present session to Session', () => {
expect(isAuthenticated(session)).toBe(true);
});
it('reports no session as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
@@ -1,9 +0,0 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -1,16 +0,0 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Principal, parseRollen } from '../domain/principal';
import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never
* actually occur. A real SSO callback (which *can* fail — session expired, access
* denied) swaps in behind this same method; that is the point where this return
* type would gain a `Result`, not before.
*
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
* `medewerkerInterceptor` already stamps onto every backend request as
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
* not change what the backend resolves or authorizes.
*/
@Injectable({ providedIn: 'root' })
export class MedewerkerAdapter {
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
async authenticate(): Promise<Principal> {
return {
kind: 'medewerker',
medewerkerId: MEDEWERKER_ID,
naam: 'H. (Hassan) Bakker',
rollen: parseRollen(currentRollen()),
};
}
}
@@ -1,50 +1,27 @@
import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
/**
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the
* two apps' login flows are meant to look this different.
*/
@Component({
selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
imports: [ButtonComponent],
template: `
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<app-form-field
i18n-label="@@login.bsnLabel"
label="BSN"
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
>
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456782"
/>
</app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field>
<app-button type="submit" variant="primary" i18n="@@login.submit"
>Inloggen met DigiD</app-button
>
</form>
<div class="form-horizontal">
<p i18n="@@login.ssoExplainer">
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
</p>
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
Inloggen met SSO
</app-button>
</div>
`,
})
export class LoginFormComponent {
bsn = '';
password = '';
submitted = output<string>();
submitted = output<void>();
}
@@ -1,36 +1,35 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
/**
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
* this page would grow one back.
*/
@Component({
selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
imports: [PageShellComponent, LoginFormComponent],
template: `
<app-page-shell
i18n-heading="@@login.heading"
heading="Inloggen"
heading="Inloggen bij het behandelportal"
width="narrow"
i18n-intro="@@login.intro"
intro="Log in op uw persoonlijke BIG-register omgeving."
intro="Voor medewerkers die aanvragen beoordelen."
>
@if (error()) {
<app-alert type="error">{{ error() }}</app-alert>
}
<app-login-form (submitted)="login($event)" />
<app-login-form (submitted)="login()" />
</app-page-shell>
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
error = signal('');
async login(bsn: string) {
const r = await this.store.login(bsn);
if (r.ok) this.router.navigate(['/dashboard']);
else this.error.set(r.error);
async login() {
await this.store.login();
this.router.navigate(['/dashboard']);
}
}
@@ -1,12 +1,7 @@
import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
import { reduce, initial } from './besluit.machine';
import { givenBesluit } from './besluit.testing';
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
const s = reduce(initial, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
const s = reduce(editingAfwijzen, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const editingGoedkeuren = givenBesluit({
tag: 'SetField',
key: 'besluit',
value: 'Goedkeuren',
});
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
const editingAfwijzenWithToelichting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
);
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './besluit.machine';
/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`.
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenBesluit = given(reduce, initial);
+11 -43
View File
@@ -26,65 +26,33 @@
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="form.verplichteVelden" datatype="html">
<source>* verplichte velden</source>
<target datatype="html">* required fields</target>
<trans-unit id="login.ssoExplainer" datatype="html">
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">15,18</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
<context context-type="linenumber">44,46</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
<context context-type="linenumber">90,92</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnLabel" datatype="html">
<source>BSN</source>
<target datatype="html">BSN</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
</context-group>
</trans-unit>
<trans-unit id="login.wachtwoordLabel" datatype="html">
<source>Wachtwoord</source>
<target datatype="html">Password</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">36,37</context>
<context context-type="linenumber">17,19</context>
</context-group>
</trans-unit>
<trans-unit id="login.submit" datatype="html">
<source>Inloggen met DigiD</source>
<target datatype="html">Log in with DigiD</target>
<source>Inloggen met SSO</source>
<target datatype="html">Log in with SSO</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">41,43</context>
<context context-type="linenumber">20,21</context>
</context-group>
</trans-unit>
<trans-unit id="login.heading" datatype="html">
<source>Inloggen</source>
<target datatype="html">Log in</target>
<source>Inloggen bij het behandelportal</source>
<target datatype="html">Log in to the treatment portal</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">14,16</context>
</context-group>
</trans-unit>
<trans-unit id="login.intro" datatype="html">
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
<target datatype="html">Log in to your personal BIG register environment.</target>
<source>Voor medewerkers die aanvragen beoordelen.</source>
<target datatype="html">For staff who assess applications.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">17,19</context>
@@ -1,55 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
(`../domain/principal`) — pure, spec'd, and testable without stubbing
`localStorage`; this just supplies the raw value. */
function restore(): Principal | null {
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
* in an httpOnly cookie/token, not web storage.
* Holds the current zorgverlener principal for the whole SSP. One
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* (the guard, the header). Persisted to localStorage — a refresh or the
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* but never the BSN itself (G1 in the `effect` below): this principal carries a
* citizen's national identifier, which the behandelportal's equivalent store does
* not have to guard against, because its `medewerker` principal has no BSN.
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
* dropped the login on the cross-bundle language switch. Trade-off: the demo
* session now survives tab close; a real portal keeps auth in an httpOnly
* cookie/token, not web storage.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore());
private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
/** Effectful command: authenticate, then store the principal on success. */
async login(bsn: string): Promise<Result<string, Principal>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present principal to Principal', () => {
expect(isAuthenticated(principal)).toBe(true);
});
it('reports no principal as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredPrincipal', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredPrincipal(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredPrincipal('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
});
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Who is logged in. Framework-free domain type.
*
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
* the union only ever holds this one member. `kind` is still a discriminant, not
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
* genuinely different type rather than a same-shaped coincidence, and what a future
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
*/
export interface Principal {
readonly kind: 'zorgverlener';
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(p: Principal | null): p is Principal {
return p !== null;
}
/**
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
* the `SessionStore` effect that writes it), so a legacy or tampered entry
* cannot resurrect one.
*/
export function parseStoredPrincipal(raw: string | null): Principal | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Principal>;
return typeof parsed?.naam === 'string'
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
: null;
} catch {
return null;
}
}
@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present session to Session', () => {
expect(isAuthenticated(session)).toBe(true);
});
it('reports no session as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
-9
View File
@@ -1,9 +0,0 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
import { Principal } from '../domain/principal';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
@@ -9,8 +9,8 @@ export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -1,9 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import {
BRIEF_LOAD_FAILED,
BriefAdapter,
BriefLoadFailure,
BriefView,
} from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store';
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
const presenter: BlobPresenter = {
open: (blob) => opened.push(blob),
download: () => {
throw new Error('not used by BriefStore');
},
};
return { presenter, opened };
}
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
TestBed.configureTestingModule({
providers: [
{ provide: BriefAdapter, useValue: adapter },
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
],
});
return TestBed.inject(BriefStore);
}
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
@@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
@@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
@@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
});
@@ -156,8 +184,10 @@ function loadedBrief(store: BriefStore): Brief {
}
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
// channel `string`) — it only ever produces the `ok: true` branch.
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load();
return store;
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
};
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
});
describe('BriefStore.previewLetter', () => {
// vi.spyOn reuses an existing spy (and its call history) if one is already on
// the property — window.open/URL.createObjectURL must be restored between tests.
afterEach(() => vi.restoreAllMocks());
it('opens the composed letter in a new tab on success', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: true,
value: blob,
});
await store.previewLetter();
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
expect(opened).toEqual([blob]);
expect(store.lastError()).toBeNull();
});
it('surfaces the error without opening a tab on failure', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false,
error: PREVIEW_FAILED,
});
await store.previewLetter();
expect(open).not.toHaveBeenCalled();
expect(opened).toHaveLength(0);
expect(store.lastError()).toBe(PREVIEW_FAILED);
});
});
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled();
});
});
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
// that); this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view };
it('a 404 drives exactly one reset(), which populates the store', async () => {
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads...
await store.load();
// Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded');
});
it('a second 404 does not drive a second reset()', async () => {
// Given every load() attempt 404s (e.g. the brief still fails to appear).
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads twice...
await store.load();
await store.load();
// Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
});
});
@@ -16,11 +16,12 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
@@ -35,6 +36,7 @@ export class BriefStore implements PendingSave {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
@@ -119,13 +121,40 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
});
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
private hasRecoveredFromMissingBrief = false;
async load() {
const r = await this.adapter.load();
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.applyLoadedView(r.value);
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.hasRecoveredFromMissingBrief = true;
await this.recoverFromMissingBrief();
} else {
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
}
}
private applyLoadedView(view: BriefView) {
this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...view });
}
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
existing `reset()` command directly (the same POST `resetDemo()` uses) and
applying whatever it returns; this NEVER calls `load()` again, so a second 404
(e.g. `reset()` itself failing) cannot loop back into this method. */
private async recoverFromMissingBrief() {
const r = await this.adapter.reset();
if (r.ok) {
this.applyLoadedView(r.value);
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -217,8 +246,8 @@ export class BriefStore implements PendingSave {
send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
the tab outlives this call; not worth a teardown hook for a POC. */
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
const r = await this.previewAdapter.preview();
@@ -227,15 +256,18 @@ export class BriefStore implements PendingSave {
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
this.blobPresenter.open(r.value);
}
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
+ step-up and audits the attempt; on success we swap the masked value in the
already-loaded caseContext (a field update, not a reload). The step-up gesture
itself is the UI's concern — this command just runs the audited server call. */
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
— this command is only reachable once that gesture has happened, so it is the one
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
hardcodes the header). */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
const r = await this.revealAdapter.reveal(true);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
@@ -0,0 +1,120 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Result, ok } from '@shared/kernel/fp';
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
import { UploadAdapter } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { OrgTemplateStore } from './org-template.store';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
footerContact: 'info@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const view: OrgTemplateAdminView = {
draft: template,
publishedVersion: 1,
history: [],
unsentBriefs: 0,
};
const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
];
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
const presenter: BlobPresenter = {
open: (blob) => opened.push(blob),
download: () => {
throw new Error('not used by OrgTemplateStore');
},
};
return { presenter, opened };
}
/** A no-op categories resource: the logo-upload sub-state is untouched by these
tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */
function fakeCategoriesResource(): ReturnType<UploadAdapter['categoriesResource']> {
const fake = { status: () => 'idle' as const, value: () => undefined };
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
}
function setup(
adapter: Partial<OrgTemplateAdapter>,
blobPresenter: BlobPresenter,
): OrgTemplateStore {
const uploadAdapter: Partial<UploadAdapter> = {
categoriesResource: () => fakeCategoriesResource(),
};
TestBed.configureTestingModule({
providers: [
{ provide: OrgTemplateAdapter, useValue: adapter },
{ provide: UploadAdapter, useValue: uploadAdapter },
{ provide: UploadShellService, useValue: {} },
{ provide: BLOB_PRESENTER, useValue: blobPresenter },
],
});
return TestBed.inject(OrgTemplateStore);
}
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief (RB-28)', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
const store = setup(
{
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> => Promise.resolve(ok(blob)),
},
presenter,
);
await store.load();
// When proefbrief() is called...
await store.proefbrief();
// Then the presenter receives exactly the rendered blob, and no error surfaces.
expect(opened).toEqual([blob]);
expect(store.lastError()).toBeNull();
});
it('surfaces the error without opening a tab on failure', async () => {
// Given a loaded sub-org template whose proefbrief call fails server-side.
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> =>
Promise.resolve({ ok: false, error: 'mislukt' }),
},
presenter,
);
await store.load();
// When proefbrief() is called...
await store.proefbrief();
// Then the presenter is never reached and the error is surfaced.
expect(opened).toHaveLength(0);
expect(store.lastError()).toBe('mislukt');
});
});
@@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadShellService } from '@shared/upload/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
@@ -20,6 +20,7 @@ import {
} from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model;
@@ -72,6 +74,9 @@ export class OrgTemplateStore implements PendingSave {
return id ? this.uploadAdapter.contentUrl(id) : null;
});
/** Preview/download link for any completed upload in the editor's document list. */
readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => {
@@ -214,7 +219,7 @@ export class OrgTemplateStore implements PendingSave {
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
this.blobPresenter.open(r.value);
}
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine';
import { givenBrief } from './brief.testing';
const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true },
@@ -64,15 +65,15 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true,
};
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
// instead of hand-assembling the 'loaded' state directly.
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
givenBrief({
tag: 'BriefLoaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
const st = givenBrief({
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
});
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './brief.machine';
/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`.
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenBrief = given(reduce, initial);
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
import { DocumentCategory } from '@shared/domain/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
@@ -1,6 +1,6 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { problemDetail } from '@shared/infrastructure/api-error';
import {
ApiClient,
BriefDecisionsDto,
@@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
* the `parse*` boundary narrows them into the domain's proper discriminated unions
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
* error string), then parse the returned brief.
* and rejects malformed shapes. Every mutation folds through `runSubmit`
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
*/
export interface BriefView {
@@ -45,16 +51,39 @@ export interface BriefView {
readonly caseContext: CaseContext;
}
/**
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
* `reset()` instead of showing an error banner) without conflating it with a real
* failure. See the class docstring above.
*/
export type BriefLoadFailure =
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
/** True when the thrown value carries an HTTP 404 status — matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
RB-23 gives the endpoint a documented 404 response). */
function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
}
@Injectable({ providedIn: 'root' })
export class BriefAdapter {
private client = inject(ApiClient);
async load(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
return r.ok ? parseBriefView(r.value) : r;
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
try {
const dto = await this.client.briefGET();
const parsed = parseBriefView(dto);
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
} catch (e) {
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
}
}
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
// globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('errorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
'Geen toegang.',
);
});
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
});
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
const okResponse = () =>
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
history.pushState({}, '', '/');
sessionStorage.clear();
});
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
setDevMode(false);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
});
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
setDevMode(true);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Role']).toBeDefined();
expect(headers['X-Subject']).toBe('111222333');
});
});
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { currentSubject } from '@shared/infrastructure/subject';
@@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in).
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) a production build sends neither header from this call (BIO-012).
*
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
@@ -43,7 +46,9 @@ export class LetterPreviewAdapter {
const subject = currentSubject();
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
cache: 'no-store',
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) },
headers: isDevMode()
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
: {},
});
} catch {
return err(PREVIEW_FAILED);
@@ -53,7 +58,9 @@ export class LetterPreviewAdapter {
}
}
async function errorMessage(res: Response): Promise<string> {
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
can call it directly instead of stubbing `globalThis.fetch`. */
export async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
import {
parseOrgTemplateAdminView,
proefbriefErrorMessage,
PROEFBRIEF_FAILED,
} from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
expect(r.ok).toBe(false);
});
});
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
).toBe('Niet gevonden.');
});
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
});
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
});
});
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { runResult, runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
@@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d a hand-written fetch, same seam as `letter-preview.adapter`.
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration a production
* build never sends it from this hand-written call either (BIO-012).
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
const r = await runResult(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
if (!res.ok) return err(await proefbriefErrorMessage(res));
return ok(await res.blob());
}
}
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
export async function proefbriefErrorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
} catch {
return PROEFBRIEF_FAILED;
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
describe('parseRevealed (TE-002 trust boundary)', () => {
it('accepts a well-formed body', () => {
const r = parseRevealed({ bigNummer: '12345678' });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('12345678');
});
// The finding's own named case: a numeric bigNummer must be rejected, not
// coerced — this is a PII reveal, not a display formatter.
it('rejects a bigNummer sent as a number', () => {
const r = parseRevealed({ bigNummer: 42 });
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
});
it('rejects a missing bigNummer field', () => {
expect(parseRevealed({}).ok).toBe(false);
});
it('rejects null and non-object bodies', () => {
expect(parseRevealed(null).ok).toBe(false);
expect(parseRevealed(undefined).ok).toBe(false);
expect(parseRevealed('12345678').ok).toBe(false);
expect(parseRevealed(42).ok).toBe(false);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
const okResponse = () =>
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
beforeEach(() => setDevMode(true));
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
});
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new RevealBigNummerAdapter().reveal(false);
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
await new RevealBigNummerAdapter().reveal(true);
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(headersWithStepUp['X-Step-Up']).toBe('true');
});
it('sends X-Role only under isDevMode()', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
setDevMode(false);
await new RevealBigNummerAdapter().reveal(true);
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(prodHeaders['X-Role']).toBeUndefined();
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
setDevMode(true);
await new RevealBigNummerAdapter().reveal(true);
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(devHeaders['X-Role']).toBeDefined();
});
});
@@ -1,47 +1,62 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/** Exported so specs can assert against the same message id instead of retyping the
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
* `stepUp: true` `BriefStore.revealBigNummer()` is the only caller and it is only
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
* so the header now reflects that gesture instead of being a constant baked into this
* adapter (BIO-006a a call that skips confirmation sends no step-up at all).
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads which also means `X-Role` is set here.
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
* `roleInterceptor` registration in `app.config.ts` a production build never sends it
* from this hand-written call either (BIO-012).
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
async reveal(stepUp: boolean): Promise<Result<string, string>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
method: 'POST',
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
headers: {
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
},
});
} catch {
return err(REVEAL_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null);
// Trust boundary: validate the shape before handing back a plain string.
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
return parseRevealed(await res.json().catch(() => null));
}
}
/** Trust boundary: validate the untrusted response shape before handing back a plain
string (TE-002) exported so a spec can call it without stubbing `globalThis.fetch`. */
export function parseRevealed(body: unknown): Result<string, string> {
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
@@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/upload/upload.machine';
import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief';
import {
MARGIN_MAX_MM,
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
import { UploadState, initialUpload } from '@shared/domain/upload.machine';
const draft: OrgTemplate = {
subOrgId: 'cibg-registers',
@@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
export class OrgTemplatePage {
protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore);
private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
protected previewUrlFor = this.store.previewUrlFor;
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
@@ -7,7 +7,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
} from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { expectTag } from '@shared/testing/expect-tag';
import {
Answers,
initial,
STEPS,
lageUren,
@@ -15,14 +14,7 @@ import {
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
import { givenIntake } from './intake.testing';
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
@@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => {
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect(
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
});
it('reveals the scholing question only when NL-hours are below the threshold', () => {
@@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => {
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
const lowThresholdState = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
{ tag: 'SetPolicy', scholingThreshold: 2000 },
);
const lowThreshold = submit(lowThresholdState);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
});
@@ -65,18 +61,21 @@ describe('navigation', () => {
});
it('Next advances once the step is valid', () => {
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
const s = expectTag(next(domestic), 'Answering');
expect(s.cursor).toBe(1);
expect(currentStep(s)).toBe('werk');
});
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const atWerk = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'SetAnswer', key: 'land', value: 'België' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
);
const edited = expectTag(
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
}),
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
'Answering',
);
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
@@ -87,57 +86,86 @@ describe('navigation', () => {
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
const submitting = submit(atReview);
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = expectTag(submit(answering(complete)), 'Submitting');
const badPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
);
expect(submit(badPunten).tag).toBe('Answering');
const good = expectTag(submit(highUren), 'Submitting');
expect(good.data.uren).toBe(4160);
expect(good.data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
'Answering',
const scholingJaNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
expect(missing.errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
const scholingNee = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
);
expect(submit(scholingNee).tag).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = expectTag(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
),
'Submitting',
const lowUrenNoScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
);
const noScholing = submit(lowUrenNoScholing);
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const lowUrenWithScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
);
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
expect(withScholing.data.aanvullendeScholing).toBe(true);
expect(withScholing.data.punten).toBe(200);
});
@@ -145,38 +173,36 @@ describe('submit', () => {
it('does not require punten for a hidden question (WP-69 §6)', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either.
const good = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })),
'Submitting',
const staleScholingNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
expect(good.data.aanvullendeScholing).toBeUndefined();
});
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
// Same stale answer, but this time punten was also filled in while uren was low.
const good = expectTag(
submit(
answering({
buitenlandGewerkt: 'nee',
uren: '1500',
scholingGevolgd: 'ja',
punten: '150',
}),
),
'Submitting',
const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '150' },
);
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
expect(good.data.aanvullendeScholing).toBeUndefined();
expect(good.data.punten).toBeUndefined();
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
@@ -23,9 +23,8 @@ import {
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
no stored bytes, so they get no link. */
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { initialUpload } from '@shared/domain/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AdminCasesStore } from './admin-cases.store';
@@ -43,7 +44,10 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
});
it('rolls back the removal when the delete fails', async () => {
// RB-20: a failed delete must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the delete fails', async () => {
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
await store.load();
@@ -51,5 +55,24 @@ describe('AdminCasesStore', () => {
await store.delete('a');
const s = store.cases();
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
expect(store.lastError()).toBe(SUBMIT_FAILED);
});
it('clears a stale error on the next delete attempt', async () => {
const deleteAny = vi
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined);
const store = setup({
listAll: () => Promise.resolve([summary('a'), summary('b')]),
deleteAny,
});
await store.load();
await store.delete('a');
expect(store.lastError()).toBe(SUBMIT_FAILED);
await store.delete('b');
expect(store.lastError()).toBeNull();
});
});
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
@@ -12,8 +13,9 @@ type Err = Error | undefined;
* Admin view of ALL cases across owners (WP-36; `cases:manage`) the back-office
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
* submitted or not the server enforces the capability).
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* failure (RB-20). Admin delete removes any case (any owner, submitted or not the
* server enforces the capability).
*/
@Injectable({ providedIn: 'root' })
export class AdminCasesStore {
@@ -22,6 +24,11 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly();
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly();
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
last-good value on a resync (only shows Loading on the first load). */
async load() {
@@ -42,16 +49,18 @@ export class AdminCasesStore {
void this.load();
}
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
AND surface it (RB-20) a silent reappearance leaves the admin guessing why. */
async delete(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
try {
await this.adapter.deleteAny(id);
} catch {
this.error.set(null);
const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED);
if (!r.ok) {
this.state.set(before); // roll back: the row reappears
this.error.set(r.error);
}
}
}
@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { ApplicationsStore } from './applications.store';
const summary = (id: string) => ({
id,
type: 'registratie',
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
documentIds: [],
createdAt: '2026-07-23T10:00:00Z',
updatedAt: '2026-07-23T10:00:00Z',
});
function setup(adapter: Partial<ApplicationsAdapter>): ApplicationsStore {
TestBed.configureTestingModule({
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
});
// The store's own constructor kicks off `load()` (dashboard revisit refresh) —
// give every test a `list` so that initial call has something to resolve.
return TestBed.inject(ApplicationsStore);
}
describe('ApplicationsStore', () => {
it('loads and parses the list', async () => {
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) });
await store.load();
const s = store.applications();
expect(s.tag).toBe('Success');
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']);
});
it('cancels optimistically and confirms via the DELETE endpoint', async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
const store = setup({
list: () => Promise.resolve([summary('a'), summary('b')]),
cancel,
});
await store.load();
await store.cancel('a');
expect(cancel).toHaveBeenCalledWith('a');
const s = store.applications();
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']);
expect(store.lastError()).toBeNull();
});
// RB-20: a failed cancel must not be silent — the row rolls back AND the store
// surfaces the error the page renders. Before RB-20 this only rolled back
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
it('rolls back the removal and surfaces the error when the cancel fails', async () => {
const cancel = vi.fn().mockRejectedValue(new Error('boom'));
const store = setup({ list: () => Promise.resolve([summary('a')]), cancel });
await store.load();
await store.cancel('a');
const s = store.applications();
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears
expect(store.lastError()).toBe(SUBMIT_FAILED);
});
it('clears a stale error on the next cancel attempt', async () => {
const cancel = vi
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(undefined);
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]), cancel });
await store.load();
await store.cancel('a');
expect(store.lastError()).toBe(SUBMIT_FAILED);
await store.cancel('b');
expect(store.lastError()).toBeNull();
});
});
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
@@ -15,7 +16,8 @@ type Err = Error | undefined;
* the row SYNCHRONOUSLY, so the block disappears deterministically no dependence on
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept In behandeling Goedgekeurd is
* computed server-side on read).
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
* surfaces `lastError` on failure (RB-20).
*/
@Injectable({ providedIn: 'root' })
export class ApplicationsStore {
@@ -24,6 +26,11 @@ export class ApplicationsStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly applications = this.state.asReadonly();
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
then, this is only the message for the alert the page renders above the list. */
private error = signal<string | null>(null);
readonly lastError = this.error.asReadonly();
constructor() {
void this.load();
}
@@ -50,16 +57,19 @@ export class ApplicationsStore {
}
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
No resync the delete succeeded, so the optimistic removal is authoritative. */
No resync the delete succeeded, so the optimistic removal is authoritative. On
failure, roll back AND surface the error (RB-20) a silent reappearance leaves the
user guessing why the block came back. */
async cancel(id: string) {
const before = this.state();
if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
}
try {
await this.adapter.cancel(id);
} catch {
this.error.set(null);
const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
if (!r.ok) {
this.state.set(before); // roll back: the block reappears
this.error.set(r.error);
}
}
}
@@ -8,10 +8,8 @@ import type {
SubmitApplicationResponse,
} from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { findConcept, loadConcept } from './find-concept';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot {
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
// server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure.
.catch(async (e) => {
const existing = await findConcept();
const existing = await findConcept(adapter, deps.type);
if (existing) return existing;
throw e;
})
@@ -140,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) {
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise<void> => {
id = linked;
return adapter
.detail(linked)
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
return loadConcept(adapter, linked).then((result) => {
if (result.tag === 'not-concept') {
id = undefined;
applyResume(null); // unknown/deleted id → start fresh
});
};
// Find the user's existing Concept of this type (at most one), if any.
const findConcept = async (): Promise<string | undefined> => {
try {
const parsed = parseApplications(await adapter.list());
return parsed.ok
? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id
: undefined;
} catch {
return undefined;
}
applyResume(null);
return;
}
applyResume(result.draft);
});
};
return {
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
await load(linked);
return;
}
const existing = await findConcept();
const existing = await findConcept(adapter, deps.type);
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { findConcept, loadConcept } from './find-concept';
// Free functions taking the adapter as a parameter (no inject()) — a plain fake
// object is enough, no Angular TestBed needed.
function fakeAdapter(overrides: Partial<ApplicationsAdapter>): ApplicationsAdapter {
return overrides as ApplicationsAdapter;
}
describe('findConcept', () => {
it('returns the id of the existing Concept of the given type', async () => {
const adapter = fakeAdapter({
list: async () => [
{
id: 'a1',
type: 'registratie',
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
],
});
await expect(findConcept(adapter, 'registratie')).resolves.toBe('a1');
});
it('returns undefined when the list has no application of the given type', async () => {
const adapter = fakeAdapter({ list: async () => [] });
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
});
it('returns undefined when the matching type is not a Concept', async () => {
const adapter = fakeAdapter({
list: async () => [
{
id: 'a1',
type: 'registratie',
status: { tag: 'Ingediend', referentie: 'R1' },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
],
});
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
});
it('returns undefined when adapter.list() resolves with an unparsable shape', async () => {
const adapter = fakeAdapter({ list: async () => 'not-an-array' as unknown as [] });
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
});
it('returns undefined when adapter.list() rejects', async () => {
const adapter = fakeAdapter({
list: async () => {
throw new Error('network down');
},
});
await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined();
});
});
describe('loadConcept', () => {
it('reads the draft off a Concept', async () => {
const adapter = fakeAdapter({
detail: async () => ({
id: 'a1',
status: { tag: 'Concept', stepIndex: 1, stepCount: 3 },
draft: { step: 1 },
}),
});
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({
tag: 'concept',
draft: { step: 1 },
});
});
it('reports a missing draft as null', async () => {
const adapter = fakeAdapter({
detail: async () => ({ id: 'a1', status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } }),
});
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'concept', draft: null });
});
it('reports not-concept when the id has moved past Concept (submitted)', async () => {
const adapter = fakeAdapter({
detail: async () => ({ id: 'a1', status: { tag: 'Ingediend', referentie: 'R1' } }),
});
await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'not-concept' });
});
it('reports not-concept when the id is unknown or deleted (detail rejects)', async () => {
const adapter = fakeAdapter({
detail: async () => {
throw new Error('404');
},
});
await expect(loadConcept(adapter, 'gone')).resolves.toEqual({ tag: 'not-concept' });
});
});
@@ -0,0 +1,48 @@
import { AanvraagType } from '@registratie/domain/aanvraag';
import {
ApplicationsAdapter,
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/**
* Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs
* before it can start writing (RB-21 / CQ-001). Free functions that take the adapter
* as a parameter, not `inject()`, so they get a direct spec without Angular TestBed.
* `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path;
* these two functions only read.
*/
/** Find the user's existing Concept of a given type (at most one), if any. */
export async function findConcept(
adapter: ApplicationsAdapter,
type: AanvraagType,
): Promise<string | undefined> {
try {
const parsed = parseApplications(await adapter.list());
return parsed.ok
? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id
: undefined;
} catch {
return undefined;
}
}
/** Outcome of loading one Concept by id: its draft (or null when it has none), or
`not-concept` when the id is not an editable Concept (submitted/gone) or the
lookup failed (unknown/deleted id) the caller treats both the same way, as
"start fresh". */
export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' };
/** Load a specific Concept by id and report whether it is still editable. */
export async function loadConcept(
adapter: ApplicationsAdapter,
id: string,
): Promise<LoadedConcept> {
try {
const dto = await adapter.detail(id);
if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' };
return { tag: 'concept', draft: dto.draft ?? null };
} catch {
return { tag: 'not-concept' };
}
}
@@ -1,9 +1,8 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import { given } from '@shared/testing/machine';
import { expectTag } from '@shared/testing/expect-tag';
import {
Draft,
RegistratieState,
STEPS,
initial,
@@ -21,28 +20,50 @@ import {
resolve,
reduce,
} from './registratie-wizard.machine';
import { givenRegistratieWizard } from './registratie-wizard.testing';
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
tag: 'Invullen',
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
upload: initialUpload,
});
/**
* Every fixture below is built by replaying real `RegistratieMsg`s through the
* real `reduce` (ADR-0006 §2) never a hand-assembled `RegistratieState`
* literal. Each helper reaches a named point in the wizard one transition at a
* time, so a spec can only assert on a state the reducer can actually produce.
*/
const toAdresValid = (): RegistratieState =>
givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'post' },
);
const validAdres = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet
const toBeroepStepWithDiploma = (): RegistratieState =>
reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a
// field or choosing a diploma is never gated by cursor position, so this is a
// real, reachable 'Invullen' state at cursor 0 — matching what `submit()`
// (which validates the whole draft regardless of cursor) is exercised against
// in the tests below.
const toFullDraftAtCursor0 = (): RegistratieState =>
givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'post' },
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
);
describe('STEPS (fixed)', () => {
it('always has the same three steps', () => {
@@ -60,46 +81,55 @@ describe('navigation', () => {
});
it('Next advances once the adres step is valid', () => {
const s = expectTag(next(invullen(validAdres)), 'Invullen');
const s = expectTag(next(toAdresValid()), 'Invullen');
expect(s.cursor).toBe(1);
expect(currentStep(s)).toBe('beroep');
});
it('requires a valid e-mail only when the channel is email', () => {
const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen');
const withEmailChannel = givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'email' },
);
const bad = expectTag(next(withEmailChannel), 'Invullen');
expect(bad.errors.email).toBeTruthy();
const good = expectTag(
next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })),
next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })),
'Invullen',
);
expect(good.cursor).toBe(1);
});
it('beroep step requires a chosen diploma', () => {
const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen');
const noDiploma = expectTag(next(toBeroepStep()), 'Invullen');
expect(noDiploma.cursor).toBe(1);
expect(noDiploma.errors.diploma).toBeTruthy();
const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen');
const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen');
expect(withDiploma.cursor).toBe(2);
});
it('Back never goes below the first step and preserves the draft', () => {
expect(back(initial)).toBe(initial);
const s = expectTag(back(invullen(validDraft, 2)), 'Invullen');
const s = expectTag(back(toControleStep()), 'Invullen');
expect(s.cursor).toBe(1);
expect(s.draft.beroep).toBe('Arts');
});
it('GaNaarStap only jumps backwards', () => {
expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
});
});
describe('adres origin (BRP vs handmatig)', () => {
it('prefillAdres flags origin brp', () => {
const s = expectTag(
prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
'Invullen',
);
expect(s.draft.adresHerkomst).toBe('brp');
@@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => {
});
it('editing a prefilled address field flips origin to handmatig', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('handmatig');
});
it('typing an address with no BRP prefill yields handmatig', () => {
const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen');
const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen');
expect(s.draft.adresHerkomst).toBe('handmatig');
});
it('editing the e-mail field does not change the address origin', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('brp');
});
it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(
invullen({
straat: 'Kerkstraat 1',
postcode: '1234 AB',
woonplaats: 'Utrecht',
correspondentie: 'post',
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
const manualAdres = givenRegistratieWizard(
{ tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' },
{ tag: 'SetField', key: 'postcode', value: '1234 AB' },
{ tag: 'SetField', key: 'woonplaats', value: 'Utrecht' },
{ tag: 'SetCorrespondentie', value: 'post' },
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
);
const indienen = expectTag(s, 'Indienen');
const indienen = expectTag(submit(manualAdres), 'Indienen');
expect(indienen.data.adresHerkomst).toBe('handmatig');
});
});
describe('kiesDiploma', () => {
it('derives the beroep from the chosen diploma and flags origin duo', () => {
const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen');
const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen');
expect(s.draft.diplomaId).toBe('d9');
expect(s.draft.beroep).toBe('Verpleegkundige');
expect(s.draft.diplomaHerkomst).toBe('duo');
@@ -152,7 +177,7 @@ describe('kiesDiploma', () => {
describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => {
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
const blocked = expectTag(next(s), 'Invullen');
expect(blocked.cursor).toBe(1);
expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy();
@@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => {
});
it('validateAll keeps only the answers to the questions that applied', () => {
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
// DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any
// diploma was chosen. That combination cannot occur in the real reducer —
// advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be
// set. Replayed here at cursor 1 instead; submit() validates the whole draft
// regardless of cursor, so the assertion below is unaffected.
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
const done = expectTag(submit(s), 'Indienen');
@@ -173,14 +203,16 @@ describe('manual diploma fallback', () => {
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen');
const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen');
expect(s.draft.diplomaHerkomst).toBe('handmatig');
expect(s.draft.beroep).toBeUndefined();
expect(s.draft.vraagIds).toEqual(maxIds);
});
it('requires a declared beroep + all maximal questions before submit', () => {
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
// DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination
// as above. Replayed at cursor 1; submit() is cursor-agnostic.
let s = kiesHandmatig(toBeroepStep(), maxIds);
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
s = declareerBeroep(s, 'Fysiotherapeut');
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
@@ -193,11 +225,11 @@ describe('manual diploma fallback', () => {
describe('submit', () => {
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen');
expect(submit(toAdresValid()).tag).toBe('Invullen');
});
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
const good = expectTag(submit(invullen(validDraft)), 'Indienen');
const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen');
expect(good.data.beroep).toBe('Arts');
expect(good.data.adres.postcode).toBe('2514 EA');
expect(good.data.adresHerkomst).toBe('brp');
@@ -205,43 +237,18 @@ describe('submit', () => {
it('resolve maps Indienen to Ingediend with the referentie', () => {
const ingediend = expectTag(
resolve(submit(invullen(validDraft)), ok('BIG-2026-001')),
resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')),
'Ingediend',
);
expect(ingediend.referentie).toBe('BIG-2026-001');
});
it('resolve maps Indienen to Mislukt on a failed submit', () => {
expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt');
});
});
describe('reduce (message-driven happy path)', () => {
// Each helper replays real messages through the real reducer up to the named
// point — no hand-assembled state literal — so each test below Givens its own
// starting point independently, one transition at a time.
const toBeroepStep = (): RegistratieState => {
let s: RegistratieState = initial;
s = reduce(s, {
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
});
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
return reduce(s, { tag: 'Next' });
};
const toControleStep = (): RegistratieState => {
const s = reduce(toBeroepStep(), {
tag: 'KiesDiploma',
diplomaId: 'd1',
beroep: 'Arts',
vraagIds: [],
});
return reduce(s, { tag: 'Next' });
};
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard.
// When the adres is prefilled, correspondentie chosen, and Next dispatched...
@@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('SubmitFailed moves Indienen to Mislukt', () => {
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('Retry returns Mislukt to Indienen with the same data', () => {
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => {
it('routes Upload messages through the upload reducer', () => {
const s = expectTag(
reduce(invullen(validDraft), {
reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
}),
@@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => {
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), {
let s = reduce(toBeroepStepWithDiploma(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => {
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), {
let s = reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -9,7 +9,7 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
} from '@shared/domain/upload.machine';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './registratie-wizard.machine';
/** Replay real `RegistratieMsg`s through the real `reduce`, starting from
`initial`. Pure TS only (no Angular) domain/ stays framework-free
(dependency-cruiser `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenRegistratieWizard = given(reduce, initial);
@@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store';
} @else if (!canManage()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
@if (store.lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
<app-async [data]="store.cases()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
@@ -51,6 +51,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
>
<div class="app-stack">
@if (cancelError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (aanvragen().length) {
<section>
@for (a of concepten(); track a.id) {
@@ -260,6 +263,8 @@ export class DashboardPage {
protected cancelAanvraag(a: Aanvraag) {
void this.apps.cancel(a.id);
}
/** RB-20: the message from a failed cancel, rendered above the list. */
protected cancelError = computed(() => this.apps.lastError());
/** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => {
@@ -37,9 +37,8 @@ import {
} from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
@@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
})
export class RegistratieWizardComponent {
private lookup = inject(RegistratieLookupStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
/** Preview/download link for a completed upload; the dev-simulation `demo-*` ids
have no stored bytes, so they get no link. */
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
this.uploadCtl.previewUrlFor(documentId);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
@@ -8,7 +8,7 @@ import {
RegistratieState,
ValidRegistratie,
} from '@registratie/domain/registratie-wizard.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { initialUpload } from '@shared/domain/upload.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = {
@@ -1,7 +1,7 @@
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { SessionStore } from '@auth/application/session.store';
import { Session } from '@auth/domain/session';
import { Principal } from '@auth/domain/principal';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { map } from '@shared/application/remote-data';
import { Role } from '@shared/domain/role';
@@ -172,6 +172,6 @@ export class DebugStateComponent {
}
}
function maskSession(s: Session | null): Session | null {
return s ? { ...s, bsn: maskBsn(s.bsn) } : null;
function maskSession(p: Principal | null): Principal | null {
return p ? { ...p, bsn: maskBsn(p.bsn) } : null;
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { BigProfile } from '@registratie/domain/big-profile';
import { REDACTED } from '@shared/kernel/pii';
import { redactProfile } from './mask';
const profile: BigProfile = {
registration: {
bigNummer: '12345678901',
naam: 'J. Jansen',
beroep: 'arts',
registratiedatum: '2015-03-01',
geboortedatum: '1980-06-12',
status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' },
},
person: {
naam: 'J. Jansen',
geboortedatum: '1980-06-12',
adres: { straat: 'Hoofdstraat 1', postcode: '1234AB', woonplaats: 'Utrecht' },
},
};
describe('redactProfile', () => {
const redacted = redactProfile(profile) as {
registration: {
bigNummer: string;
naam: string;
beroep: string;
registratiedatum: string;
geboortedatum: string;
status: unknown;
};
person: { naam: string; geboortedatum: string; adres: string };
};
it('masks the BIG-nummer to its last 3 digits', () => {
expect(redacted.registration.bigNummer).toBe('********901');
});
it('redacts the name on both the registration and the person', () => {
expect(redacted.registration.naam).toBe(REDACTED);
expect(redacted.person.naam).toBe(REDACTED);
});
it('redacts every date of birth', () => {
expect(redacted.registration.geboortedatum).toBe(REDACTED);
expect(redacted.person.geboortedatum).toBe(REDACTED);
});
it('redacts the address', () => {
expect(redacted.person.adres).toBe(REDACTED);
});
it('keeps structural/decision-relevant fields untouched', () => {
expect(redacted.registration.beroep).toBe('arts');
expect(redacted.registration.registratiedatum).toBe('2015-03-01');
expect(redacted.registration.status).toEqual(profile.registration.status);
});
});
@@ -74,7 +74,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
// Submit requests carry only the fields the server re-validates (UX-only fields
// stay on the client). ponytail: a real submit would carry the full application.
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Telefoon);
@@ -70,8 +70,12 @@ public static class Mappers
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).
/// 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
/// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner
/// list cannot be added that forgets to.
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
a.ToSummaryDto(now) with { Owner = a.Owner };
a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) };
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
@@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
modelBuilder.Entity<BriefEntity>(e =>
{
e.HasKey(b => b.BriefId);
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant)
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
+15 -18
View File
@@ -47,17 +47,15 @@ public static class BriefStore
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
/// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null —
/// 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.
public static BriefEntity? Get(string owner)
{
lock (_gate)
{
using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner);
db.Briefs.Add(created);
db.SaveChanges();
return created;
return db.Briefs.FirstOrDefault(e => e.Owner == owner);
}
}
@@ -70,10 +68,10 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
var outcome = BriefRules.CanSave(e.Status, isDrafter);
if (outcome != Outcome.Ok) return (outcome, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
e.Status = BriefRules.StatusAfterSave(e.Status);
db.SaveChanges();
return (Outcome.Ok, e);
}
@@ -86,8 +84,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections));
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
@@ -109,7 +107,8 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
var outcome = BriefRules.CanSend(e.Status);
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
@@ -152,7 +151,7 @@ public static class BriefStore
// from the drafter (a drafter cannot approve their own letter). The SoD check is
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
// inline check exactly.
// inline check exactly (BriefRules.CanDecide preserves that order).
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
{
lock (_gate)
@@ -160,15 +159,13 @@ public static class BriefStore
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId);
if (outcome != Outcome.Ok) return (outcome, null);
e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
@@ -1,3 +1,5 @@
using BigRegister.Domain.People;
namespace BigRegister.Api.Data;
/// <summary>
@@ -58,7 +60,7 @@ public static class DocumentStore
db.Documents.Add(doc);
db.SaveChanges();
}
Audit("upload", doc.DocumentId, categoryId, owner);
Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3));
return doc;
}
@@ -73,13 +75,13 @@ public static class DocumentStore
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds, string owner)
{
var set = localIds.ToHashSet();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
return db.Documents.Where(d => set.Contains(d.LocalId) && d.Owner == owner).ToList();
}
}
@@ -156,7 +158,7 @@ public static class DocumentStore
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-user", documentId, categoryId, owner);
Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3));
return DeleteResult.Ok;
}
@@ -178,6 +180,12 @@ public static class DocumentStore
return true;
}
/// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive
/// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass
/// <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
/// the authorization key and stays untouched. Masking here instead would have to guess
/// which actors are BSNs and which are role names.</summary>
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate)
@@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization;
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — one of the two actor
/// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker
/// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is
/// the only implementation today.
/// the only implementation today, and is registered only in Development (<c>Program.cs</c>,
/// RB-09/BIO-002).
/// </summary>
public interface IIdentityProvider
{
CallerIdentity Resolve(HttpContext ctx);
/// <summary>Null when the request carries no identity a real implementation can vouch for —
/// e.g. no credential at all. Returning null, rather than inventing a default, is what makes
/// "unauthenticated" representable; the identity-resolution middleware (<c>Program.cs</c>)
/// turns a null into a 401 instead of a silent identity substitution.</summary>
CallerIdentity? Resolve(HttpContext ctx);
}
@@ -13,6 +13,11 @@ namespace BigRegister.Domain.Authorization;
/// 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
/// swap happens.
///
/// Registered only in Development (<c>Program.cs</c>, RB-09/BIO-002) — it always invents a
/// 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
/// <see cref="IIdentityProvider.Resolve"/>, this stub never has "no identity" to report.
/// </summary>
public sealed class StubIdentityProvider : IIdentityProvider
{
@@ -0,0 +1,63 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
namespace BigRegister.Domain.Letters;
/// <summary>
/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each
/// 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
/// decision can be unit-tested without a booted host or a real SQLite file. Callers
/// pass the values the rule needs, never the entity, so this stays pure.
///
/// Returns <see cref="BriefStore.Outcome"/> — that type already exists as the domain
/// concept the whole brief flow reports through (`BriefResult` in Program.cs switches
/// on it directly), so this reuses it rather than inventing a second result shape.
/// </summary>
public static class BriefRules
{
/// Save is drafter-only, and only while the letter is editable (draft/rejected).
/// Order matches the store's original inline check: role before status, so a
/// non-drafter always sees Forbidden even against a non-editable status.
public static BriefStore.Outcome CanSave(BriefStatusDto status, bool isDrafter)
{
if (!isDrafter) return BriefStore.Outcome.Forbidden;
if (status.Tag is not ("draft" or "rejected")) return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
/// A save on a rejected letter reopens it to draft (mirrors the FE reducer); a save
/// on a draft leaves the status untouched.
public static BriefStatusDto StatusAfterSave(BriefStatusDto status) =>
status.Tag == "rejected" ? new BriefStatusDto("draft") : status;
/// Every required section needs at least one block before a letter is submittable.
public static bool RequiredFilled(IReadOnlyList<LetterSectionDto> sections) =>
sections.All(s => !s.Required || s.Blocks.Count > 0);
/// Submit is drafter-only, only from draft, and only once every required section
/// is filled.
public static BriefStore.Outcome CanSubmit(BriefStatusDto status, bool isDrafter, bool requiredFilled)
{
if (!isDrafter) return BriefStore.Outcome.Forbidden;
if (status.Tag != "draft" || !requiredFilled) return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
/// Send only from approved — sending is a mechanical dispatch step, not role-gated
/// (Authz.CanActOn already returns true unconditionally for BriefAction.Send).
public static BriefStore.Outcome CanSend(BriefStatusDto status) =>
status.Tag == "approved" ? BriefStore.Outcome.Ok : BriefStore.Outcome.Conflict;
/// Approve/Reject share this guard: the caller must be entitled to act on the letter
/// (four-eyes/SoD, via the existing <see cref="Authz.CanActOn"/>), and the letter must
/// be submitted. The entitlement check runs BEFORE the status check — Forbidden takes
/// priority over Conflict, matching the store's original order exactly.
public static BriefStore.Outcome CanDecide(BriefAction action, BriefStatusDto status, Principal principal, string drafterId)
{
if (!Authz.CanActOn(action, principal, drafterId)) return BriefStore.Outcome.Forbidden;
if (status.Tag != "submitted") return BriefStore.Outcome.Conflict;
return BriefStore.Outcome.Ok;
}
}
@@ -56,7 +56,7 @@ public static class LetterHtml
{
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
foreach (var block in section.Blocks)
RenderParagraphs(sb, block.Content.Paragraphs, defs);
RenderParagraphs(sb, block.Content.Paragraphs, defs, at);
sb.Append("</section>");
}
sb.Append("</div>");
@@ -89,7 +89,8 @@ public static class LetterHtml
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
string at)
{
string? openList = null;
foreach (var para in paragraphs)
@@ -101,14 +102,14 @@ public static class LetterHtml
openList = para.List;
}
sb.Append(openList is null ? "<p>" : "<li>");
foreach (var node in para.Nodes) RenderNode(sb, node, defs);
foreach (var node in para.Nodes) RenderNode(sb, node, defs, at);
sb.Append(openList is null ? "</p>" : "</li>");
}
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
}
private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs, string at)
{
switch (node.Type)
{
@@ -122,7 +123,7 @@ public static class LetterHtml
var key = node.Key ?? "";
var def = defs.GetValueOrDefault(key);
var label = def?.Label ?? key;
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label, at)) : Enc($"[NOG IN TE VULLEN: {label}]"));
break;
}
}
@@ -131,11 +132,11 @@ public static class LetterHtml
// single demo applicant (SeedData.Registration — no per-brief resolved value is
// ever stored, see the class doc above). Falls back to the label itself for any
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
private static string ResolveAuto(string key, string label) => key switch
private static string ResolveAuto(string key, string label, string at) => key switch
{
"naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
"datum" => FormatDatumNl(at),
_ => label,
};
@@ -0,0 +1,18 @@
namespace BigRegister.Domain.People;
/// <summary>
/// One redaction rule for identifiers that must not leave the server in full (BSN,
/// BIG-nummer). Lives in <c>Domain/</c> because three layers need it — the DTO mappers
/// (<c>Contracts/Mappers.cs</c>), the audit writes (<c>Data/DocumentStore.cs</c>) and the
/// endpoints themselves — and a second hand-rolled copy is exactly how one of them drifts
/// into leaking. Mirrors the FE <c>maskTail</c> (<c>libs/shared/src/ui/debug-state/mask.ts</c>)
/// so wire redaction and the dev panel agree on what a masked value looks like.
/// </summary>
public static class Pii
{
/// Keep the last <paramref name="keep"/> characters, mask the rest. Idempotent: masking an
/// already-masked value is a no-op, so a defence-in-depth second call is harmless.
public static string MaskTail(string value, int keep) =>
value.Length <= keep ? new string('*', value.Length)
: new string('*', value.Length - keep) + value[^keep..];
}
@@ -9,12 +9,6 @@ namespace BigRegister.Domain.Submissions;
/// </summary>
public static class SubmissionRules
{
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
+274 -128
View File
@@ -12,6 +12,7 @@ using BigRegister.Domain.Documents;
using BigRegister.Domain.Features;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using BigRegister.Api.Zgw;
@@ -50,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C
// 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
// DigiD/employee-SSO provider swaps in without touching a consumer.
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
//
// RB-09/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
// 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
// start in Production rather than falling through to a per-request 401, means a misconfigured
// deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope
// for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply
// cannot start, which is the correct fail-closed behaviour for "no identity provider available".
if (builder.Environment.IsDevelopment())
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"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 " +
"this POC yet. Register one before deploying to Production.");
// WP-49: 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
@@ -110,16 +126,37 @@ app.Use(async (ctx, next) =>
// WP-53: resolve the acting citizen once per request, right after correlation — everything
// downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of
// re-deriving "who" itself.
// re-deriving "who" itself. RB-09/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
// flow downstream as a silent identity substitution.
var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
app.Use(async (ctx, next) =>
{
ctx.SetCaller(identityProvider.Resolve(ctx));
var identity = identityProvider.Resolve(ctx);
if (identity is null)
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
ctx.SetCaller(identity);
await next(ctx);
});
app.UseSwagger();
app.UseSwaggerUI();
// RB-15/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
// 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
// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs
// 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
// 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
// note that this exact file has already broken that tool once.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(SpaCors);
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
@@ -164,6 +201,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
Results.Ok(StamdataCatalog.All.Select(t =>
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
.Gate("StamdataAdmin")
.WithName("stamdataTables")
.Produces<List<StamdataTableSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -174,21 +212,29 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
{
var t = StamdataCatalog.Find(table);
if (t is null) return Results.NotFound();
var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();
DateOnly? peildatumWaarde = null;
// RB-16/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
// admin-gated but still user-supplied string needs the same 400 path every other bad-input
// check in this file uses, not a crash.
if (peildatum is { Length: > 0 } p)
{
if (!DateOnly.TryParse(p, out var parsed))
return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest);
peildatumWaarde = parsed;
}
var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows();
return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
}))
.Gate("StamdataAdmin")
.WithName("stamdataTable")
.Produces<StamdataTableDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
// --- POST: submits. The server is the authority; it re-validates and decides. ---
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon)))
.Produces<ReferentieResponse>()
@@ -196,10 +242,47 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
// --- Document upload ---
// --- reads ---
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// 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
// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than
// 403s, so the endpoint never confirms that a document id exists.
api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) =>
{
var doc = DocumentStore.Get(documentId);
var allowed = ctx.Caller() switch
{
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
var caller => Authz.CanBeoordelen(caller),
};
if (doc is null || !allowed) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the
// same answer an id that never existed gets.
var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
});
// --- writes ---
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
// and size authoritatively; stores metadata only (no file bytes / PII held).
@@ -226,29 +309,6 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
})
.ExcludeFromDescription();
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
});
// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
@@ -263,17 +323,21 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// Admin delete (seam): a real system requires an admin role; here an X-Admin header
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
// 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
// (RB-08/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).
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
.Gate("CasesAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
// --- Applications (aanvragen): the system of record the dashboard reads. ---
// --- reads ---
// WP-53: 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
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
@@ -288,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
.Produces<ApplicationDetailDto>()
.Produces(StatusCodes.Status404NotFound);
// --- writes ---
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
{
// Feature flag (WP-47): self-service registration can be closed by an admin.
@@ -422,11 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
// --- reads ---
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Gate("CasesAdmin")
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
Results.Ok(AuthzAuditStore.List()
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
.ToList())))
.Gate("CasesAdmin")
.Produces<List<AuthzAuditDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- writes ---
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
// DELETE /applications/{id}. A missing id is a 404.
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
{
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
app.Logger.LogInformation("admin case delete id={Id}", id);
return Results.NoContent();
}))
.Gate("CasesAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
// 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 —
@@ -435,6 +530,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList())))
.Gate("Beoordelen")
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -450,13 +546,17 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
var docs = DocumentStore.ByIds(c.DocumentIds)
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
// Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and
// MaskTail is idempotent, but IZaakSource has a second implementation whose Owner
// is mapped from OpenZaak, so this stays as the guarantee for this response.
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
// 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 decisions = new BeoordelingDecisionsDto(canBesluiten);
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
}))
.Gate("Beoordelen")
.Produces<BeoordelingViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
@@ -497,6 +597,10 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
statusCode: StatusCodes.Status409Conflict);
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
// records what they decided. Without it /beheer/audit cannot answer "who rejected this
// aanvraag", which is the question the trail exists for.
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
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
@@ -513,6 +617,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
}))
.Gate("Beoordelen")
.Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -549,27 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
// /uploads and /brief/reveal-bignummer.
.ExcludeFromDescription();
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
// DELETE /applications/{id}. A missing id is a 404.
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
{
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
app.Logger.LogInformation("admin case delete id={Id}", id);
return Results.NoContent();
}))
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
Results.Ok(AuthzAuditStore.List()
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
.ToList())))
.Produces<List<AuthzAuditDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// 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).
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
@@ -589,23 +673,53 @@ api.MapGet("/flags", () =>
Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList()))
.Produces<List<FeatureFlagDto>>();
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
.Gate("FlagsAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
// toggle) — no real identities in this POC. ---
// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
// identities in this POC. ---
// --- reads ---
api.MapGet("/brief", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
return ToView(ctx, e);
// RB-23/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)
// — this GET is a pure query and 404s when there is nothing to read yet.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
if (e is null) return Results.NotFound();
return Results.Ok(ToView(ctx, e));
})
.Produces<BriefViewDto>();
.Produces<BriefViewDto>()
.Produces(StatusCodes.Status404NotFound);
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
// 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
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
// RB-23: 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
// 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.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
if (e is null) return Results.NotFound();
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
})
.ExcludeFromDescription();
// --- writes ---
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
@@ -620,7 +734,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
{
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now());
LogBrief("submit", r);
LogBrief(ctx, "submit", r);
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
@@ -631,7 +745,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
api.MapPost("/brief/approve", (HttpContext ctx) =>
{
var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now());
LogBrief("approve", r);
LogBrief(ctx, "approve", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefViewDto>()
@@ -641,7 +755,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now());
LogBrief("reject", r);
LogBrief(ctx, "reject", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefViewDto>()
@@ -654,7 +768,7 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
// port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now());
LogBrief("send", r);
LogBrief(ctx, "send", r);
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
})
.Produces<BriefViewDto>()
@@ -671,7 +785,10 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
var canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp;
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);
// RB-02/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
// brief exists per owner, so the id added nothing the acting principal did not imply.
AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal);
if (!allowed)
return Results.Problem(
detail: canReveal
@@ -684,31 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// OpenAPI doc, same seam as /brief/preview and uploads.
.ExcludeFromDescription();
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
// 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
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
})
.ExcludeFromDescription();
// Proefbrief: the admin's unpublished draft template rendered over a fixture
// brief, so the appearance can be checked before publishing touches real letters.
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var view = OrgTemplateStore.AdminView(subOrgId);
if (view is null) return Results.NotFound();
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.ExcludeFromDescription();
api.MapPost("/brief/reset", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
@@ -723,25 +815,44 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
// as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
// --- reads ---
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List())))
.Gate("OrgAdmin")
.WithName("orgTemplates")
.Produces<List<SubOrgSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
.Gate("OrgAdmin")
.WithName("orgTemplateGET")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
// Proefbrief: the admin's unpublished draft template rendered over a fixture
// brief, so the appearance can be checked before publishing touches real letters.
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var view = OrgTemplateStore.AdminView(subOrgId);
if (view is null) return Results.NotFound();
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.Gate("OrgAdmin")
.ExcludeFromDescription();
// --- writes ---
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var reject = OrgTemplateRules.RejectDraft(req.Draft);
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
}))
.Gate("OrgAdmin")
.WithName("orgTemplatePUT")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
@@ -756,6 +867,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
subOrgId, r.Version, r.AffectedUnsentBriefs);
return r is not null ? Results.Ok(r) : Results.NotFound();
}))
.Gate("OrgAdmin")
.WithName("orgTemplatePublish")
.Produces<PublishOrgTemplateResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -763,6 +875,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
.Gate("OrgAdmin")
.WithName("orgTemplateRollback")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -770,17 +883,20 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub
app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
// One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial
// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their
// own effect, e.g. publish).
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
//
// RB-07/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
// 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,
// not by the endpoint, so a new admin endpoint cannot be added that forgets it.
IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
{
var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageOrgTemplates(principal)) return action();
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal);
var ok = Authz.CanManageOrgTemplates(principal);
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
statusCode: StatusCodes.Status403Forbidden);
}
@@ -790,8 +906,9 @@ IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
{
var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanEditStamdata(principal)) return action();
AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal);
var ok = Authz.CanEditStamdata(principal);
AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.",
statusCode: StatusCodes.Status403Forbidden);
}
@@ -801,8 +918,9 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
{
var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageCases(principal)) return action();
AuditAuthz(ctx, "cases:manage", "cases", false, principal);
var ok = Authz.CanManageCases(principal);
AuditAuthz(ctx, "cases:manage", "cases", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.",
statusCode: StatusCodes.Status403Forbidden);
}
@@ -813,18 +931,23 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
{
if (Authz.CanBeoordelen(ctx.Caller())) return action();
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
var ok = Authz.CanBeoordelen(ctx.Caller());
AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx));
if (ok) return action();
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
statusCode: StatusCodes.Status403Forbidden);
}
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47).
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action)
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a
// 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,
// and this is the surface CQ-004/ADR-C-009 hinge on.
IResult FlagsAdmin(HttpContext ctx, string resource, Func<IResult> action)
{
var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageFeatureFlags(principal)) return action();
AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal);
var ok = Authz.CanManageFeatureFlags(principal);
AuditAuthz(ctx, "flags:manage", resource, ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.",
statusCode: StatusCodes.Status403Forbidden);
}
@@ -856,12 +979,6 @@ void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exceptio
AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid);
}
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
static string MaskTail(string value, int keep) =>
value.Length <= keep ? new string('*', value.Length)
: new string('*', value.Length - keep) + value[^keep..];
static string Now() => DateTimeOffset.UtcNow.ToString("o");
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
@@ -875,7 +992,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
// behandel scherm can show whom/what it concerns without brief/ importing registratie.
// The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal
// endpoint returns the full value, gated + audited.
new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie));
new CaseContextDto(SeedData.Registration.Naam, Pii.MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie));
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
@@ -886,20 +1003,29 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
action, r.outcome, r.entity?.Status.Tag ?? "-");
// RB-07/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
// no trail. Resource is the bare "brief" (RB-02: 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.
void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r)
{
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
action, r.outcome, r.entity?.Status.Tag ?? "-");
AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx));
}
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference.
// — 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
// client-chosen header value do not share a cached result.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString()
? $"{ctx.Caller().SubjectId}:{k}"
: null;
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
@@ -936,5 +1062,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
return result;
}
// RB-12/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
// 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
// to a test host. RouteInventoryTests.cs cross-checks every mapped route against either this
// marker or an explicit, named allow-list — see that file for the actual safety net.
// Public, not internal: RouteInventoryTests.cs (a separate assembly, no InternalsVisibleTo
// wired up for one marker type) reads this metadata directly off EndpointDataSource.
public sealed record AuthzGateMetadata(string Wrapper);
public static class AuthzGateEndpointExtensions
{
public static TBuilder Gate<TBuilder>(this TBuilder builder, string wrapper)
where TBuilder : IEndpointConventionBuilder
{
builder.WithMetadata(new AuthzGateMetadata(wrapper));
return builder;
}
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { }
@@ -24,7 +24,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller);
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
?? throw new InvalidOperationException($"ZGW GET {Redact(url)} returned null body.");
}
public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null)
@@ -32,7 +32,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
using var res = await SendWithRetryAsync(
() => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller);
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
?? throw new InvalidOperationException($"ZGW POST {Redact(url)} returned null body.");
}
/// <summary>
@@ -42,7 +42,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
/// partial commit on the two non-idempotent ZGW POSTs (<c>/statussen</c>, <c>/rollen</c>) and
/// retrying risks a duplicate write — the create-zaak/document POSTs are additionally
/// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie).
/// A non-transient (or exhausted) failure throws with the status + a body snippet, which
/// A non-transient (or exhausted) failure throws with the status + the redacted path, which
/// <c>Program.cs</c>'s submit endpoint catches and records as a flagged divergence rather
/// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section).
/// </summary>
@@ -73,15 +73,26 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
continue;
}
var body = await res.Content.ReadAsStringAsync();
var snippet = body.Length > 500 ? body[..500] : body;
var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}";
// RB-05/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
// 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;
// ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler) is the deliberate opt-in for the rest.
var message = $"ZGW {req.Method} {Redact(req.RequestUri)} failed: {(int)res.StatusCode} {res.ReasonPhrase}";
var status = res.StatusCode;
res.Dispose();
throw new HttpRequestException(message, null, status);
}
}
/// <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
/// ZGW url may be interpolated into a message that is logged or persisted (RB-05).</summary>
private static string Redact(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.GetLeftPart(UriPartial.Path) : url.Split('?')[0];
private static string Redact(Uri? url) => url is null ? "(no uri)" : url.GetLeftPart(UriPartial.Path);
private static bool IsTransient(HttpStatusCode status) => status is
HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout;
+88 -124
View File
@@ -194,6 +194,16 @@
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
@@ -210,45 +220,6 @@
}
}
},
"/api/v1/registrations": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegistratieRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReferentieResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/change-requests": {
"post": {
"tags": [
@@ -439,7 +410,14 @@
"description": "No Content"
},
"403": {
"description": "Forbidden"
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
@@ -708,6 +686,73 @@
}
}
},
"/api/v1/admin/audit": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuthzAuditDto"
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/admin/cases/{id}": {
"delete": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found"
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/werkvoorraad": {
"get": {
"tags": [
@@ -854,73 +899,6 @@
}
}
},
"/api/v1/admin/cases/{id}": {
"delete": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found"
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/admin/audit": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuthzAuditDto"
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/me": {
"get": {
"tags": [
@@ -1022,6 +1000,9 @@
}
}
}
},
"404": {
"description": "Not Found"
}
}
},
@@ -2468,23 +2449,6 @@
},
"additionalProperties": false
},
"RegistratieRequest": {
"type": "object",
"properties": {
"diplomaHerkomst": {
"type": "string",
"nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"RegistrationDto": {
"type": "object",
"properties": {
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -34,7 +35,10 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
list.EnsureSuccessStatusCode();
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var mine = cases.Single(x => x.Id == a.Id);
Assert.False(string.IsNullOrEmpty(mine.Owner)); // admin list carries the owner
// RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is
// read by someone who is not the subject.
Assert.Equal("******782", mine.Owner);
Assert.DoesNotContain(DocumentStore.DemoOwner, mine.Owner);
}
finally
{
@@ -1,8 +1,10 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Features;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -26,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
}
private async Task<string> UploadAsOwner()
{
var form = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(file, "file", "diploma.pdf");
form.Add(new StringContent("diploma"), "categoryId");
form.Add(new StringContent("local-rb08"), "localId");
form.Add(new StringContent("registratie"), "wizardId");
var res = await _client.PostAsync("/api/v1/uploads", form);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!.DocumentId;
}
[Fact]
public async Task A_denied_admin_action_is_recorded()
{
@@ -43,6 +59,85 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
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
/// "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.
[Fact]
public async Task An_allowed_admin_action_is_recorded()
{
(await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode();
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
/// 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
/// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases"
/// 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
/// `CasesAdmin` endpoint that would write its own row and confound the count) —
/// rather than mere presence, which this class's other cases:manage calls would
/// already satisfy even without the fix.
[Fact]
public async Task An_admin_upload_delete_is_recorded()
{
bool IsCasesManageAllow(AuthzAuditEntry e) =>
e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin";
var documentId = await UploadAsOwner();
var before = AuthzAuditStore.List().Count(IsCasesManageAllow);
(await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}")))
.EnsureSuccessStatusCode();
Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow));
}
/// The flag toggle writes no log line of its own, so the audit row is the only record that
/// it happened — a bare "feature-flags" resource would not say which flag.
[Fact]
public async Task A_feature_flag_toggle_records_which_flag_changed()
{
var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}");
toggle.Content = JsonContent.Create(new { enabled = false });
(await _client.SendAsync(toggle)).EnsureSuccessStatusCode();
Assert.Contains(await AuditLog(), e =>
e.Action == "flags:manage" && e.Decision == "allow" &&
e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False");
}
/// Every brief transition funnels through LogBrief, so all four are covered by the audit
/// call living there. The allow side is asserted in
/// <c>BriefEndpointTests.Submit_succeeds_when_required_sections_filled</c>, which already has
/// the fill-the-sections scaffolding; this is the refused side — a rejected transition must
/// leave a row rather than being dropped.
[Fact]
public async Task A_refused_brief_transition_is_recorded()
{
// No brief exists for this subject and nothing is filled in → illegal transition.
Assert.Equal(HttpStatusCode.Conflict, (await _client.PostAsync("/api/v1/brief/submit", null)).StatusCode);
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
/// column called `Resource` was invisible to it — and one was there, concatenated as
/// `"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.
[Fact]
public async Task No_audit_row_carries_a_subjects_bsn()
{
const string subject = "999999990";
var reveal = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
reveal.Headers.Add("X-Subject", subject);
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(reveal)).StatusCode);
var bsns = new[] { subject, DocumentStore.DemoOwner };
foreach (var e in await AuditLog())
foreach (var field in new[] { e.Action, e.Resource, e.Decision, e.Role, e.At, e.CorrelationId })
Assert.DoesNotContain(bsns, bsn => field.Contains(bsn, StringComparison.Ordinal));
}
[Fact]
public void The_audit_schema_carries_no_pii()
{
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -150,6 +151,12 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
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
// what they decided, which is the question /beheer/audit exists to answer.
Assert.Contains(AuthzAuditStore.List(), e =>
e.Action == "aanvraag:besluit" && e.Decision == "allow" &&
e.Resource == $"aanvraag/{a.Id}/Goedkeuren");
}
finally
{
@@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return new SaveBriefRequest(sections);
}
private async Task<BriefDto> Get()
/// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that
/// needs one present creates it explicitly through `POST /brief/reset`
/// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses.
private async Task<BriefDto> SeedBrief()
{
BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
var res = await _client.PostAsync("/api/v1/brief/reset", null);
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(view);
return view.Brief;
}
@@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return req;
}
// --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. ---
[Fact]
public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty()
public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
{
var brief = await Get();
BriefStore.Reset();
var res = await _client.GetAsync("/api/v1/brief");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
// The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call.
// Assert directly against the store, not only the HTTP status, so a regression that
// reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404.
Assert.Null(BriefStore.Get(DocumentStore.DemoOwner));
}
[Fact]
public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty()
{
var brief = await SeedBrief();
Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
@@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// global passages + the arts-scoped one; no other-beroep passages leak in.
@@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// Case context is joined onto the screen DTO for the behandel scherm header.
@@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Save_is_drafter_only()
{
var brief = await Get();
var brief = await SeedBrief();
var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver");
@@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_blocks_on_empty_required_section()
{
await Get();
await SeedBrief();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
}
@@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_succeeds_when_required_sections_filled()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief));
@@ -160,12 +180,17 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(submitted);
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.
// Resource is the bare "brief" — never the owner's BSN (RB-02).
Assert.Contains(AuthzAuditStore.List(),
e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief");
}
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -182,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reject_returns_comments()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -197,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync(
@@ -213,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Send_only_from_approved()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -231,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await Get();
var brief = await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
@@ -264,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{
var brief = await Get();
var brief = await SeedBrief();
// Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -0,0 +1,147 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Letters;
namespace BigRegister.Tests.Domain;
public class BriefRuleTests
{
private static BriefStatusDto Status(string tag) => new(tag);
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
private static readonly Principal Approver = new(PrincipalRole.Approver);
// --- CanSave -----------------------------------------------------------------
[Theory]
[InlineData("draft")]
[InlineData("rejected")]
public void A_drafter_may_save_a_draft_or_rejected_letter(string tag) =>
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSave(Status(tag), isDrafter: true));
[Theory]
[InlineData("submitted")]
[InlineData("approved")]
[InlineData("sent")]
public void A_drafter_may_not_save_a_non_editable_letter(string tag) =>
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSave(Status(tag), isDrafter: true));
[Theory]
[InlineData("draft")]
[InlineData("submitted")]
public void A_non_drafter_is_forbidden_to_save_regardless_of_status(string tag) =>
// Role is checked before status: Forbidden wins even against an otherwise-open status.
Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSave(Status(tag), isDrafter: false));
// --- StatusAfterSave -----------------------------------------------------------
[Fact]
public void Saving_a_rejected_letter_reopens_it_to_draft() =>
Assert.Equal("draft", BriefRules.StatusAfterSave(Status("rejected")).Tag);
[Fact]
public void Saving_a_draft_letter_leaves_its_status_unchanged() =>
Assert.Equal("draft", BriefRules.StatusAfterSave(Status("draft")).Tag);
// --- RequiredFilled --------------------------------------------------------------
private static LetterSectionDto Section(string key, bool required, int blockCount) =>
new(key, key, required, Enumerable.Range(0, blockCount)
.Select(i => new LetterBlockDto("freeText", $"{key}-{i}", new RichTextBlockDto(Array.Empty<ParagraphDto>())))
.ToList());
[Fact]
public void No_required_sections_means_nothing_to_fill() =>
Assert.True(BriefRules.RequiredFilled(Array.Empty<LetterSectionDto>()));
[Fact]
public void An_optional_empty_section_does_not_block_submission() =>
Assert.True(BriefRules.RequiredFilled(new[] { Section("slot", required: false, blockCount: 0) }));
[Fact]
public void A_required_section_with_a_block_is_filled() =>
Assert.True(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 1) }));
[Fact]
public void A_required_section_with_no_blocks_is_not_filled() =>
Assert.False(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 0) }));
[Fact]
public void One_unfilled_required_section_blocks_submission_even_if_others_are_filled() =>
Assert.False(BriefRules.RequiredFilled(new[]
{
Section("kern", required: true, blockCount: 1),
Section("bijlage", required: true, blockCount: 0),
}));
// --- CanSubmit -----------------------------------------------------------------
[Fact]
public void A_drafter_may_submit_a_filled_draft() =>
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: true));
[Fact]
public void A_drafter_may_not_submit_an_unfilled_draft() =>
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: false));
[Fact]
public void A_drafter_may_not_submit_a_letter_that_is_not_a_draft() =>
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("submitted"), isDrafter: true, requiredFilled: true));
[Fact]
public void A_non_drafter_is_forbidden_to_submit_even_a_filled_draft() =>
// Role is checked before status/completeness: Forbidden wins over Conflict.
Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSubmit(Status("draft"), isDrafter: false, requiredFilled: true));
// --- CanSend ---------------------------------------------------------------------
[Fact]
public void An_approved_letter_may_be_sent() =>
Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSend(Status("approved")));
[Theory]
[InlineData("draft")]
[InlineData("submitted")]
[InlineData("rejected")]
[InlineData("sent")]
public void Only_an_approved_letter_may_be_sent(string tag) =>
Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSend(Status(tag)));
// --- CanDecide (Approve/Reject shared guard) --------------------------------------
[Theory]
[InlineData(BriefAction.Approve)]
[InlineData(BriefAction.Reject)]
public void An_approver_may_decide_a_submitted_letter_drafted_by_someone_else(BriefAction action) =>
Assert.Equal(
BriefStore.Outcome.Ok,
BriefRules.CanDecide(action, Status("submitted"), Approver, drafterId: BriefStore.DrafterId));
[Fact]
public void A_drafter_may_not_approve_or_reject() =>
Assert.Equal(
BriefStore.Outcome.Forbidden,
BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Drafter, drafterId: BriefStore.DrafterId));
[Fact]
public void An_approver_may_not_decide_a_letter_they_drafted_themselves() =>
// Four-eyes / SoD: the acting approver id happens to equal the letter's drafterId.
Assert.Equal(
BriefStore.Outcome.Forbidden,
BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Approver, drafterId: BriefStore.ApproverId));
[Fact]
public void An_approver_may_not_decide_a_letter_that_is_not_submitted() =>
Assert.Equal(
BriefStore.Outcome.Conflict,
BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.DrafterId));
[Fact]
public void Entitlement_is_checked_before_status_forbidden_wins_over_conflict() =>
// Same actor as drafter AND a non-submitted status: still Forbidden, not Conflict —
// matches the store's original check order (Authz.CanActOn before the status guard).
Assert.Equal(
BriefStore.Outcome.Forbidden,
BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.ApproverId));
}
@@ -4,14 +4,6 @@ namespace BigRegister.Tests.Domain;
public class SubmissionRuleTests
{
[Fact]
public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact]
public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact]
public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
@@ -69,26 +69,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Equal(1000, dto.ScholingThreshold);
}
[Fact]
public async Task Registration_with_duo_diploma_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotNull(body);
Assert.StartsWith("BIG-2026-", body.Referentie);
}
[Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
var contentType = res.Content.Headers.ContentType;
Assert.NotNull(contentType);
Assert.Contains("application/problem+json", contentType.ToString());
}
[Fact]
public async Task Change_request_with_valid_phone_succeeds()
{
@@ -101,11 +81,16 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
}
[Fact]
public async Task Change_request_with_bad_phone_is_rejected()
public async Task Change_request_with_bad_phone_is_rejected_with_problem_details()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { telefoon = "nope" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
// 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.
var contentType = res.Content.Headers.ContentType;
Assert.NotNull(contentType);
Assert.Contains("application/problem+json", contentType.ToString());
}
[Fact]
@@ -215,8 +200,12 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
public async Task User_delete_blocked_with_409_once_linked_to_submission()
{
var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
// Through the real submit path (RB-06 deleted POST /registrations, which was the only
// other caller of DocumentStore.Link and had no ownership guard on it).
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } });
submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
@@ -224,11 +213,13 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
[Fact]
public async Task Admin_delete_requires_admin_role()
{
// RB-08: routed through CasesAdmin (cases:manage), like the other admin-cases
// endpoints, not the standalone X-Admin header this used to accept.
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true");
req.Headers.Add("X-Role", "admin");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
}
@@ -45,6 +45,31 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
}
// RB-18/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 —
// 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
// independent submissions.
[Fact]
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
{
var sharedKey = Guid.NewGuid().ToString();
var callerARequest = ChangeRequestWithKey(sharedKey);
callerARequest.Headers.Add("X-Subject", "111222333");
var callerA = await _client.SendAsync(callerARequest);
callerA.EnsureSuccessStatusCode();
var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();
var callerBRequest = ChangeRequestWithKey(sharedKey);
callerBRequest.Headers.Add("X-Subject", "999888777");
var callerB = await _client.SendAsync(callerBRequest);
callerB.EnsureSuccessStatusCode();
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
}
[Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{
@@ -80,6 +80,44 @@ public class LetterHtmlTests
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html");
// A minimal brief whose body renders the "datum" placeholder — the golden-file
// fixture above never uses it in the body, only in the letterhead, so it cannot
// exercise ResolveAuto's "datum" case (TE-007).
private static BriefEntity FixtureBriefWithDatumInBody() => new()
{
BriefId = "datum-brief-1",
Owner = "golden",
Beroep = "arts",
TemplateId = "besluit-arts",
DrafterId = BriefStore.DrafterId,
Placeholders = new[]
{
new PlaceholderDefDto("datum", "Datum", true),
},
Sections = new()
{
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>
{
new("freeText", "kern-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[] { new RichTextNodeDto("placeholder", Key: "datum") }),
})),
}),
},
Status = new BriefStatusDto("draft"),
};
private static string ExtractLetterheadDate(string html) =>
Regex.Match(html, "<dt>Datum</dt><dd>([^<]+)</dd>").Groups[1].Value;
private static string ExtractBodyDatumParagraph(string html)
{
var bodyStart = html.IndexOf("<div class=\"letter__body\">", StringComparison.Ordinal);
var bodyEnd = html.IndexOf("<div class=\"letter__signature\">", StringComparison.Ordinal);
var body = html[bodyStart..bodyEnd];
return Regex.Match(body, "<p>([^<]+)</p>").Groups[1].Value;
}
[Fact]
public void Render_matches_the_golden_file()
{
@@ -88,6 +126,29 @@ public class LetterHtmlTests
Assert.Equal(golden, html);
}
[Fact]
public void Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock()
{
const string historicalAt = "2019-03-14T08:00:00.0000000+00:00";
var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false);
Assert.Equal("14 maart 2019", ExtractBodyDatumParagraph(html));
}
[Fact]
public void Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at()
{
// A historical `at` (an archive re-render, a back-dated letter) is the case
// where the letterhead and the body datum placeholder could disagree within
// one document, if the body still read the wall clock (TE-007).
const string historicalAt = "2019-03-14T08:00:00.0000000+00:00";
var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false);
Assert.Equal(ExtractLetterheadDate(html), ExtractBodyDatumParagraph(html));
}
[Fact]
public void Every_letter_prefixed_class_exists_in_letter_css()
{
@@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_increments_the_version()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
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"));
res.EnsureSuccessStatusCode();
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_appends_to_the_version_history()
{
ResetStores();
await _client.GetAsync("/api/v1/brief");
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_counts_the_unsent_briefs_it_affects()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
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"));
res.EnsureSuccessStatusCode();
@@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
private async Task WalkBriefToSentThenRepublish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
@@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Admin_cannot_slip_into_the_brief_review_flow()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
@@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{
ResetStores();
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode();
@@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.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/approve", role: "approver"));
@@ -0,0 +1,150 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests;
/// RB-12/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
/// 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
/// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in
/// being hard to fool.
///
/// Every mapped route must be accounted for exactly one of two ways:
/// - it carries an <see cref="AuthzGateMetadata"/> marker (<c>.Gate("XAdmin")</c>, added at the
/// call site in Program.cs) naming one of the five admin authz wrappers, or
/// - it is named, with a reason, in <see cref="AllowList"/> below.
///
/// The allow-list is deliberately not "public routes" — most of its entries are NOT public.
/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN
/// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the
/// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point
/// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down")
/// generalised to every route that isn't wrapper-gated: the reviewer reads a name and a reason,
/// not silence.
public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
// Not TestWebApplicationFactory's HttpClient — this never issues a request, only reads the
// route table off the host's DI container. Uses the shared per-class isolated db file (see
// TestWebApplicationFactory's own doc comment) rather than a bare `new
// WebApplicationFactory<Program>()`, which would share the mutable static Db.ConnectionString
// with whatever other test class last set it and race "table already exists" against it.
private TestWebApplicationFactory Factory { get; } = factory;
private sealed record AllowListEntry(string Method, string Pattern, string Reason);
private static readonly AllowListEntry[] AllowList =
[
// --- Orchestrator probes: no data, no PII, run before any identity concern applies. ---
new("GET", "/health", "Liveness probe for orchestrators."),
new("GET", "/health/ready", "Readiness probe for orchestrators."),
// --- Static/reference demo data (SeedData & friends): identical for every caller in
// this POC (one seeded citizen), nothing to scope by. ---
new("GET", "/api/v1/dashboard-view", "Static reference data (SeedData) — same for every caller in this POC."),
new("GET", "/api/v1/notes", "Static reference data (SeedData.Notes) — same for every caller in this POC."),
new("GET", "/api/v1/brp/address", "Static BRP reference fixture — same for every caller in this POC."),
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/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/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()),
// 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/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/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("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."),
new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."),
new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."),
new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."),
new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."),
// --- External caller, not a Principal at all. ---
new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."),
// --- Brief (letter composition): PRD-0002's own status-machine enforcement is the
// 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,
// not a missing one. ---
new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."),
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/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/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("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."),
new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."),
];
private static readonly HashSet<string> KnownWrappers =
["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"];
private static IEnumerable<RouteEndpoint> RealRoutes(EndpointDataSource source) =>
source.Endpoints.OfType<RouteEndpoint>()
// MapGroup's own catch-all/description endpoints carry no HTTP method — not a route
// an HTTP client can actually hit distinctly, so not this test's concern.
.Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null);
private static string Key(string method, string pattern) => $"{method} {pattern}";
[Fact]
public void Every_mapped_route_is_authz_gated_or_on_the_named_allow_list()
{
var source = Factory.Services.GetRequiredService<EndpointDataSource>();
var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern));
var seenAllowListKeys = new HashSet<string>();
var unaccounted = new List<string>();
foreach (var route in RealRoutes(source))
{
var pattern = route.RoutePattern.RawText!;
foreach (var method in route.Metadata.GetMetadata<HttpMethodMetadata>()!.HttpMethods)
{
var key = Key(method, pattern);
var gated = route.Metadata.GetMetadata<AuthzGateMetadata>() is { } gate && KnownWrappers.Contains(gate.Wrapper);
var listed = allowed.ContainsKey(key);
if (listed) seenAllowListKeys.Add(key);
if (!gated && !listed) unaccounted.Add(key);
}
}
Assert.True(unaccounted.Count == 0,
"Route(s) with no authz gate and no allow-list entry — either add `.Gate(\"XAdmin\")` " +
"at the mapping site, or add a named, reasoned entry to RouteInventoryTests.AllowList:\n" +
string.Join("\n", unaccounted));
// The allow-list is a decision log, not a wishlist — an entry for a route that no longer
// exists (renamed, removed) is exactly the kind of drift this test exists to catch.
var stale = allowed.Keys.Except(seenAllowListKeys).ToList();
Assert.True(stale.Count == 0,
"Allow-list entry with no matching live route (stale — the route was renamed or " +
"removed):\n" + string.Join("\n", stale));
}
/// Every `.Gate(...)` call must name one of the five known wrappers — a typo here would
/// silently fall back to "unaccounted for" above, but pinning it down explicitly gives a
/// clearer failure than the generic route-mismatch message.
[Fact]
public void Every_gate_marker_names_a_known_admin_wrapper()
{
var source = Factory.Services.GetRequiredService<EndpointDataSource>();
var unknown = RealRoutes(source)
.Select(r => r.Metadata.GetMetadata<AuthzGateMetadata>())
.Where(g => g is not null)
.Select(g => g!.Wrapper)
.Where(w => !KnownWrappers.Contains(w))
.Distinct()
.ToList();
Assert.True(unknown.Count == 0, "Unknown wrapper name(s) in a .Gate(...) call: " + string.Join(", ", unknown));
}
}
@@ -59,6 +59,16 @@ public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFi
Assert.Empty(table.Rows);
}
/// RB-16/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
/// bad-input check in this endpoint file returns.
[Fact]
public async Task Unparseable_peildatum_is_400_not_500()
{
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions?peildatum=not-a-date", role: "admin"));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
[Fact]
public async Task Unknown_table_is_404()
{
@@ -1,6 +1,8 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
@@ -93,4 +95,32 @@ public class StubIdentityProviderTests
var caller = Resolve(role: "admin", medewerker: "m.jansen");
Assert.Equal(PrincipalRole.Admin, caller.Role);
}
/// RB-09/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
/// a caller, never a source of "no identity" itself. A request with genuinely no headers at
/// all still resolves to the seeded citizen, unchanged.
[Fact]
public void Never_returns_null_even_with_no_headers_at_all()
{
Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext()));
}
}
/// RB-09/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 —
/// so a Production build must fail at startup rather than silently resolving every request to
/// the seeded citizen (the failure mode BIO-002 documents).
public class ProductionIdentityProviderTests
{
[Fact]
public void Production_environment_with_no_real_identity_provider_fails_at_startup()
{
using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder => builder.UseEnvironment("Production"));
// The throw happens while the app builds services, before any request can be served —
// triggered here by the test host materialising that host to hand out a client.
Assert.ThrowsAny<Exception>(() => factory.CreateClient());
}
}
@@ -0,0 +1,48 @@
using System.Net;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace BigRegister.Tests;
/// RB-15/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
/// reachable in every environment, including a real deployment. Both are now gated behind
/// `app.Environment.IsDevelopment()`.
public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
[Fact]
public async Task Swagger_document_is_served_in_development()
{
// 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
// regression guard that the gate didn't also break the documented `npm run gen:api` /
// local-dev-Swagger-UI experience.
var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
/// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which
/// 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
/// 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` —
/// 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.
[Fact]
public async Task Swagger_document_is_not_served_outside_development()
{
// 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
// isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's
// implementation note records the "table already exists" collision a bare factory hits
// by sharing the mutable static Db.ConnectionString instead).
using var staging = factory.WithWebHostBuilder(builder => builder
.UseEnvironment("Staging")
.ConfigureTestServices(services => services.AddSingleton<IIdentityProvider, StubIdentityProvider>()));
var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
}
@@ -0,0 +1,99 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// Who may see what about an upload. RB-01/BIO-004: GET /uploads/{id}/content and
/// /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
/// owner-scoped. RB-04/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.
public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private const string OtherCitizen = "999999990";
private async Task<string> UploadAsOwner()
{
var form = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(file, "file", "diploma.pdf");
form.Add(new StringContent("diploma"), "categoryId");
form.Add(new StringContent("local-rb01"), "localId");
form.Add(new StringContent("registratie"), "wizardId");
var res = await _client.PostAsync("/api/v1/uploads", form);
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!.DocumentId;
}
private Task<HttpResponseMessage> Get(string path, params (string Name, string Value)[] headers)
{
var req = new HttpRequestMessage(HttpMethod.Get, path);
foreach (var (name, value) in headers) req.Headers.Add(name, value);
return _client.SendAsync(req);
}
[Fact]
public async Task The_owner_can_read_the_bytes()
{
var id = await UploadAsOwner();
Assert.Equal(HttpStatusCode.OK, (await Get($"/api/v1/uploads/{id}/content")).StatusCode);
}
[Fact]
public async Task Another_citizen_gets_404_not_403()
{
var id = await UploadAsOwner();
// 404, not 403: a foreign id must not be distinguishable from one that never existed.
Assert.Equal(HttpStatusCode.NotFound,
(await Get($"/api/v1/uploads/{id}/content", ("X-Subject", OtherCitizen))).StatusCode);
}
[Fact]
public async Task A_behandelaar_can_read_a_linked_document()
{
var id = await UploadAsOwner();
Assert.Equal(HttpStatusCode.OK,
(await Get($"/api/v1/uploads/{id}/content", ("X-Medewerker", "medewerker-1"))).StatusCode);
}
[Fact]
public async Task A_medewerker_without_the_behandelaar_rol_does_not()
{
var id = await UploadAsOwner();
Assert.Equal(HttpStatusCode.NotFound,
(await Get($"/api/v1/uploads/{id}/content",
("X-Medewerker", "medewerker-1"), ("X-Rollen", "geen"))).StatusCode);
}
[Fact]
public async Task The_document_audit_trail_records_a_masked_actor()
{
var id = await UploadAsOwner();
(await _client.DeleteAsync($"/api/v1/uploads/{id}")).EnsureSuccessStatusCode();
var rows = DocumentStore.AuditLog.Where(e => e.DocumentId == id).ToList();
Assert.Equal(new[] { "upload", "delete-user" }, rows.Select(e => e.Action));
Assert.All(rows, e => Assert.Equal("******782", e.Actor));
// The unmasked BSN stays where it is load-bearing — the ownership key, not the trail.
Assert.All(rows, e => Assert.DoesNotContain(DocumentStore.DemoOwner, e.Actor));
}
[Fact]
public async Task Status_reports_another_citizens_localId_as_unknown()
{
await UploadAsOwner();
var res = await Get("/api/v1/uploads/status?localIds=local-rb01", ("X-Subject", OtherCitizen));
res.EnsureSuccessStatusCode();
var status = (await res.Content.ReadFromJsonAsync<UploadStatusDto>())!;
var item = Assert.Single(status.Results);
Assert.Equal("unknown", item.Status);
Assert.Null(item.DocumentId);
}
}
@@ -38,7 +38,8 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag);
Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases
// RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto.
Assert.Equal("******782", mine.Owner);
}
finally
{
@@ -111,6 +111,32 @@ public class ZgwDivergenceTests
Assert.Null(stored.ZgwError);
}
/// RB-05/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
/// its errors) or the request's query string (ZGW filters travel there, and one of them is
/// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`).
[Fact]
public async Task A_recorded_divergence_carries_no_response_body_and_no_query_string()
{
// The zaak POST succeeds; the statustypen GET — the one call here that carries a query
// string — fails, so the recorded message is built from a url that has one.
var stub = new ZgwStubHandler(SuccessBody,
(url, _) => url.StartsWith($"{ZtBase}/statustypen") ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
using var factory = Factory(stub);
using var client = factory.CreateClient();
var id = await CreateConcept(client);
(await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }))
.EnsureSuccessStatusCode();
var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError;
Assert.NotNull(error);
Assert.DoesNotContain("stub failure", error); // no response-body snippet
Assert.DoesNotContain("?", error); // no query string
Assert.Contains($"{ZtBase}/statustypen", error); // the path still routes the failure
Assert.Contains("503", error);
}
private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
@@ -13,8 +13,24 @@
**47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below
traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric.
**HALT.** This file is the deliverable. No Implementation Agent starts until a human has
approved it. Nothing in this pass was implemented; no source file was modified.
**HALT lifted 2026-08-27** — the operator approved the backlog and Phase 3 started.
**CD batch 1 (RB-01..RB-06) is implemented**, one commit per ticket on branch
`refactor/adr-c-006-shared-route-guards`, each with a note in `implementation/rb-0N.md`.
Batches 26 are untouched. The `Status` column below is the source of truth.
Two batch-1 findings had knock-on effects a later ticket must not re-derive:
- **RB-01's residual is RB-09's problem.** Both callers of the document-content endpoint
reach it as a plain browser navigation (`<a href>` / `previewUrl`), carrying no identity
header and passing through no interceptor, so `StubIdentityProvider` answers with the
seeded citizen. The links keep working only because one citizen owns every document in
the POC. That is BIO-002; RB-09 needs this endpoint to receive a real credential.
- **RB-06 also deleted `SubmissionRules.RejectRegistratie`**, which the row did not ask for.
It was reachable only from the deleted endpoint and contradicted by the live submit path.
Recorded as the ticket's one judgement call in `implementation/rb-06.md`.
`Pii.MaskTail` now lives in `Domain/People/Pii.cs` (moved out of `Program.cs` by RB-03) —
**RB-11 and any later redaction work should use it rather than hand-rolling a second copy.**
---
@@ -84,41 +100,41 @@ deployed first_, not _must ship together_.
Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative
16-row "Compliance review required" list, carries it — regardless of priority.
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ------ |
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | open |
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | SM | Med | **P1** | 2 | — | **SIGN-OFF** | open |
| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | open |
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | open |
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | open |
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | open |
| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | open |
| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | open |
| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | open |
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | open |
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | open |
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | open |
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | open |
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate``Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | open |
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open |
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | SM | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | SM | Low | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open |
| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status |
| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- |
| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** |
| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | SM | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** |
| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** |
| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** |
| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** |
| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** |
| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** |
| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** |
| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** |
| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** |
| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate``Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** |
| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** |
| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** |
| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | SM | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** |
| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | SM | Low | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** |
| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** |
| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **done** |
| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **done** |
| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** |
---
@@ -235,10 +251,10 @@ diff** (CLAUDE.md's own precedence rule: "the docs win — update this file").
| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status |
| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- |
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | pending |
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | pending |
| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | pending |
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | pending |
| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** |
| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** |
| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** |
| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** |
| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** |
**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it
@@ -1,13 +1,112 @@
# Agent run status
| Agent | Status | Last module processed | Last updated | Notes |
| --------------- | -------------------------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. |
| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. |
| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. |
| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. |
| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs``Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". |
| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. |
| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. |
| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). |
| consolidation | halted (awaiting approval) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. |
| Agent | Status | Last module processed | Last updated | Notes |
| --------------- | ------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. |
| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. |
| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. |
| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. |
| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs``Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". |
| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. |
| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. |
| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). |
| consolidation | complete (approved) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. **Approved 2026-08-27; HALT lifted.** |
## Phase 3 — implementation
**All six batches complete, 2026-08-28. All 33 code tickets (RB-01..RB-33) and the four gated ADR-fixes (ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009) merged to `refactor/adr-c-006-shared-route-guards`, one commit per ticket. `npm run ci` green after every merge, verified independently before trusting any agent's own report.** Every batch carrying a **SIGN-OFF** ticket shipped only after the architect approval recorded 2026-08-27 (HALT lifted). Three of the four ADR-fixes required a matching CLAUDE.md correction (§2 once, §4 twice); all three landed in the same diff as their ADR amendment, per CLAUDE.md's own precedence rule.
Across the six batches, several tickets turned out to be factually wrong, incomplete, or overstated relative to the actual code, and every one was reported rather than silently patched over — among them: two ADR-fixes (ADR-C-001's stale out-of-scope bullet, ADR-C-007 catching only half of its own finding), RB-18 (real scope one endpoint, not the several the stale line numbers implied), RB-23 (an unmentioned second `GetOrCreate` call site forced onto the same contract change), RB-25 (overstated which methods the missing token actually blocked), RB-27 (correctly declined to extract abort-vs-error into a signature that cannot express it, and declined an optional move outside its stated scope), RB-28 (TE-006's "cannot test the success case" claim was already false when written), and RB-31 (found a genuinely unreachable state — cursor 2 with no diploma chosen — that the old hand-rolled fixture had been asserting). None of these weakened a ticket; each was implemented correctly once the discrepancy was named.
| CD batch | Tickets | Status | Notes |
| -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). |
| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. |
| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth``bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | |
| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site**`GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. |
| 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. |
| 6 | RB-31, RB-32, RB-33 | **complete** | All three merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Dispatched as one wave — no file overlap at all (four machine specs in three apps, one docs file, one testing helper plus its one call site). **RB-31 found a real ADR-0006 violation, not a false alarm.** Two `registratie-wizard` tests asserted a state — cursor 2, no diploma chosen — that the real reducer cannot produce, since advancing past `beroep` (cursor 1→2) requires `KiesDiploma`/`KiesHandmatig` to have already run. This is exactly what forcing fixtures through message replay is for: a hand-rolled literal let an impossible state sit in the suite undetected. Fixed by replaying to cursor 1 and applying the diploma choice there instead; `submit()` validates the whole draft regardless of cursor, so the assertions are byte-identical to before — reviewed the diff before merging to confirm the old and new test bodies check the same thing. `intake.machine.spec.ts` also had an existing, correct `intake.testing.ts` sitting unused in its own folder, imported only by the acceptance spec — now wired to both. **RB-32 added the missing `language-switcher` row and took the ticket's explicitly-optional second step**: a ~14-line drift guard in `check-tokens.sh` that diffs every `CIBG-GAP EXTENSION` marker's component directory against the register's rows and fails naming what's missing. Verified the regex before trusting it — two existing rows carry parenthetical suffixes (`wizard-shell (error summary only)`) and the extraction correctly captures only the backtick-quoted name. This closes ADR-0003's own predicted failure mode ("if markers and this table drift, trust the code and fix the table") permanently rather than fixing it once more. **RB-33 made the real adopt-or-delete call the finding asked for, and chose delete.** `unwrapOk` had zero consumers anywhere in the repo since it shipped; manufacturing a first caller purely to satisfy the ticket would have removed no actual duplication, since there was only one occurrence to begin with. Deleted the helper and its doc mention; left the one candidate call site's inline guard alone, since it already satisfies ADR-0006 §3's real requirement (never a cast). |
| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. |
**Standing caveat for every batch:** `dotnet test` reports one failure,
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container. It fails identically on a stashed tree — it is not
caused by any of these tickets. `npm run ci` does not run it.
## Gate integrity — read before trusting any "ci green" in this file
`scripts/ci-local.sh` chained six of its steps as `cmd1 && cmd2` under `set -e`. Bash exempts
every command of an AND-OR list **except the last** from `errexit`, so a crash in `cmd1` was
swallowed: the paired check never ran and the script still printed "local CI passed". Verified
directly — `bash -c 'set -e; false && true; echo hi'` prints `hi` and exits 0.
This hid a **real** `gen:api` crash introduced by RB-09 (`dotnet swagger`'s design-time host
defaults to Production, which RB-09 made throw). `.github/workflows/ci.yml` runs each step as
its own `- run:` and would have caught it, so the local gate was strictly **weaker** than the
remote one — the opposite of its purpose. The worst instance was
`ng build ssp --localize && ng build behandelportal --localize`: a missing English translation
in ssp could not fail the run.
Fixed in `build: stop ci-local.sh swallowing the first half of every paired step`. **Every
"ci green" recorded for batch 1 and for RB-07/RB-10/RB-11 predates that fix** and is therefore
weaker than it reads; the batch-2 completion run above is the first one made on the honest gate
(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been
re-verified under the fixed gate either.
## Dispatching implementation agents — what actually goes wrong
Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at
least one of these. Put all of it in the prompt.
1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree
branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`,
an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero:
`git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if
absent, and report which it was. The one agent that was not told to do this found out by luck.
2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background,
then ended their turn waiting for a notification that never usefully arrived; one finished its
work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to
pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because
"run it in the foreground" alone does not defeat the 120s default.
3. **`git checkout <file>` destroys the work.** Agents verify a test is red by undoing the fix;
restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits.
4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent
regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to
expect it and never hand-edit.
5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`.
Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory<Program>()`
races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` +
`IClassFixture` idiom. Do not trust a backend green while another agent is testing.
6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by
ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`.
7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were
handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that
is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit
check as the first instruction in every prompt — it is the highest-value line in there. Give the
_current_ HEAD, not the batch's starting commit, when tickets merge sequentially.
8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents
died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the
uncommitted work intact. Sending each agent a message resumed it from its own transcript and it
finished from exactly where it stopped — nothing was redone. Check `git -C <worktree> status`
before assuming work is lost.
9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five
merges. Cause: the central prettier pass reflows the table's column widths, so the whole table
conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's
table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell
and printing only the differing cells before discarding either side — do not eyeball a 33-row
table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`.
10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines
in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the
route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the
prompt and re-run it centrally before merging.
11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping
order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to
prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to
commit the regenerated pair, or CI's drift job fails on a correct change.
12. **The backlog's own "depends on" column is not exhaustive — check actual imports before parallelizing a wave.** Batch 5's table said only "25/26/27 depend on 24"; it never mentioned that RB-24 rewrites imports in two of RB-28's three target files. `grep -rln` for the moved module's import path against every other open ticket's target files, before deciding what runs in parallel — not after a conflict.
**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong
about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and
RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review
of the diff alone.
@@ -0,0 +1,69 @@
# ADR-C-001 — rewrite ADR-0001's worked example against the shipped system
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-001
## What was wrong
ADR-0001's §"Worked example in this POC" opened with _"This POC has no real backend (static
mock JSON + fake submit timers), so the 'BFF output' is a static file"_. That premise is
false and every path the section cited was gone. The decision itself was intact; only the
description had drifted.
## What changed
| File | Change |
| -------------------------------------------------------- | --------------------------------------------------------------------------- |
| `docs/reference/architecture/0001-...md` §Worked example | rewritten against `backend/src/BigRegister.Api`; all six paths repointed |
| same file, §Out of scope here | 4 bullets → 2, plus a paragraph recording which two were discharged and why |
No code changed. No CLAUDE.md edit was required for this finding.
## Paths corrected, each verified
| Claimed | Actual |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "no real backend … static file" | `backend/src/BigRegister.Api`, `var api = app.MapGroup("/api/v1")` at `Program.cs:168` |
| `public/mock/dashboard-view.json` | `GET /api/v1/dashboard-view` (`Program.cs:172`) |
| `public/mock/intake-policy.json` | `GET /api/v1/intake/policy` (`Program.cs:193`) |
| `src/app/registratie/contracts/dashboard-view.dto.ts` | `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` |
| `src/app/registratie/infrastructure/dashboard-view.adapter.ts` | `apps/ssp/.../infrastructure/dashboard-view.adapter.ts`, `parseDashboardView` at `:50` |
| `src/app/herregistratie/contracts/intake-policy.dto.ts` | **deleted** — the DTO is now the generated `IntakePolicyDto`; the adapter is `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts` |
`apps/ssp/public/mock/` does not exist (`ls`: no such directory).
## The finding was wrong about one out-of-scope bullet
ADR-C-001 said to _"reduce §Out of scope to the two items still genuinely open (the
`BigProfileStore` optimistic-update race, and session persistence / multi-tab sync)"_,
carrying the original bullet's parenthetical **"`SessionStore` is in-memory"**. That
parenthetical is no longer true, so the bullet could not be kept verbatim.
- `apps/ssp/src/app/auth/application/session.store.ts:13` reads
`parseStoredPrincipal(localStorage.getItem(STORAGE_KEY))`, and `:41` writes it back.
Session persistence **has landed** (RB-10 extracted the parser, RB-13 renamed it
`parseStoredPrincipal`). The file even carries a `ponytail:` note explaining the choice of
`localStorage` over `sessionStorage`.
- Multi-tab sync has **not** landed: `grep` for a `storage` event listener across `apps` and
`libs` returns nothing.
The bullet was therefore narrowed to multi-tab sync only, and states that the session itself
now persists. Recording this because the finding, taken literally, would have re-asserted a
false claim in the same edit that removed two others.
The other two survivors were verified rather than assumed: `BigProfileStore` still holds
`pending` as a bare `signal(false)` with `begin`/`confirm`/`rollback` mutating it
(`big-profile.store.ts:61-74`), so the concurrent-submit race is real.
## Discharged bullets, both verified
- _"Runtime DTO validation on **every** endpoint (only the dashboard view has it)"_ — 33
distinct `export function parse*` boundary functions exist across `apps` and `libs`.
- _"Real OpenAPI/TypeSpec codegen toolchain"_`npm run gen:api` (`package.json:12`) runs
`dotnet swagger tofile` then `nswag run`, emitting
`libs/shared/src/infrastructure/api-client.ts` (2329 lines). CI's `api-client-drift` job
regenerates and runs `git diff --exit-code` (`.github/workflows/ci.yml:319-321`).
## Scope discipline
Descriptive drift only, as the finding states. The decision, the options table, the two
policy shapes and the migration sequence are untouched.
@@ -0,0 +1,64 @@
# ADR-C-003 — state that the generated client is the wire contract
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-003
## What was wrong
ADR-0001 set "one source of truth that generates types for both sides" as the target state.
The code reached it. CLAUDE.md §4 still stated the pre-codegen rule — _"DTO lives in
`contracts/`"_ — as standing law, so §4 could be cited to justify both deleting the four
survivors and adding new hand-written DTOs for already-generated endpoints.
## What changed
| File | Change |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `docs/reference/architecture/0001-...md` | **new** §"Where the contract lives, after codegen" |
| `CLAUDE.md` §4 | the flat "DTO lives in `contracts/`" rule replaced with the generated-client rule + the two exceptions |
No code changed. Per CLAUDE.md's own precedence rule, the ADR was amended first and
CLAUDE.md corrected to match, in one diff.
## The decision the finding asked for: the four survivors stay
ADR-C-003 required an explicit, recorded decision on the four remaining hand-written DTOs.
**They stay**, all four under exception case 2 ("the generator types the shape too loosely").
This is not a preference — adopting the generated shapes would violate CLAUDE.md §3.
Evidence. NSwag emits every property as optional, and flattens a discriminated union into a
bag of optional fields:
| | generated (`api-client.ts`) | hand-written (`dashboard-view.dto.ts`) |
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `DashboardViewDto` | `registration?`, `person?`, `decisions?` — all optional (`:2017`) | all three required |
| `RegistrationDto` | six optional fields (`:2202`) | six required fields |
| `RegistrationStatusDto` | **one flat record of five optional strings**, `tag?: string` (`:2211`) | a real union of three variants, `tag: 'Geregistreerd' \| 'Geschorst' \| 'Doorgehaald'`, per-variant fields required |
The generated `RegistrationStatusDto` makes `{ tag: 'Geregistreerd', doorgehaaldOp: '…' }`
representable. That is precisely the illegal state CLAUDE.md §3 exists to forbid, and the
`parse*` boundary would have to reconstruct the union by hand anyway.
The ADR therefore records that retiring these four is **not** a cleanup to schedule. It
becomes correct only if the backend annotates its DTOs so the generator emits required
properties and real unions — which names the actual prerequisite instead of leaving the
question open.
## Verified counts, not carried over from the finding
- Hand-written `contracts/*.dto.ts`: **4**
`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
and `libs/beheer/src/contracts/stamdata.dto.ts`.
- All four duplicate generated types **by the same names**: `BrpAddressDto` (`:1997`),
`DashboardViewDto` (`:2017`), `DuoLookupDto` (`:2056`), `DuoDiplomaDto` (`:2047`),
`PolicyQuestionDto` (`:2168`), `ManualDiplomaPolicyDto` (`:2106`), `StamdataColumnDto`
(`:2246`), `StamdataTableDto` (`:2253`), `StamdataTableSummaryDto` (`:2261`). None is a
codegen gap — the finding's "case 1" has no occupant today, which is worth knowing.
- The `parse*` boundary is restated as mandatory regardless of type provenance. The amendment
says why in one line: a generated type is a compile-time claim about the wire, not a
runtime guarantee.
## Gate released
ADR-C-003 blocked any ticket that would delete the four `contracts/*.dto.ts` files or add a
hand-written DTO for a generated endpoint. No open ticket needed it. The rule is now written
down, so a future one can be judged against it rather than against a stale §4.
@@ -0,0 +1,65 @@
# ADR-C-007 — repoint ADR-0003's WP-67 paths and fix its point 4
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-007
## What was wrong
Two separate defects in one ADR. Every file path in ADR-0003 predated WP-67's monorepo move,
and decision point 4 made a claim about `app-alert` that the code contradicts.
## What changed
| File | Change |
| ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `docs/reference/architecture/0003-...md` | points 1, 2, 4 and both §Consequences bullets rewritten |
| `CLAUDE.md` §2 | the `alert` parenthetical corrected to `skeleton`/`spinner` + a denial for `alert` |
No code changed. ADR first, CLAUDE.md to match, one diff.
## Paths, each verified
| Claimed | Actual |
| ------------------------ | ---------------------------------------------------------------------- |
| `src/styles.scss` | `libs/shared/styles.scss` — one copy, both apps' `angular.json:41,169` |
| `src/index.html` | `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` |
| `.storybook/` | `.storybook-ssp/` and `.storybook-behandelportal/` |
| `src/docs/cibg-gaps.mdx` | `libs/shared/docs/cibg-gaps.mdx` |
**One path in the finding's list needed no change.** ADR-C-007 implied point 1's
`public/cibg-huisstijl/` had moved with the rest. It has not: `public/` is still at the repo
root, and both apps' `angular.json` asset entries read `"input": "public"` (`:38`, `:166`).
Both Storybook configs serve it as `staticDirs: ['../public']`. Point 1's vendoring path is
left as written; only its `index.html` clause changed.
## Point 4: the finding was right, and understated
ADR-C-007 flagged the `.alert` half of point 4. Verified: `libs/shared/src/ui/alert/alert.component.ts`
documents itself as a _"Thin wrapper over the vendored `.feedback feedback-*` classes"_, its
template binds `.feedback-info/-success/-warning/-error`, its only local CSS is a 3-line flex
fix, and it carries **no** `CIBG-GAP EXTENSION` marker. `grep` confirms `feedback-error` is
present in `public/cibg-huisstijl/css/huisstijl.css` — the class is vendored, so `alert` is not
a gap.
**The finding missed that the same sentence's second claim is also false.** Point 4 said "the
header/side-nav use `.nav` + a local blue bar". They do not:
- `site-header.component.ts` composes the vendored `.titlebar` and `.logo__*` classes
(`grep` confirms `titlebar` in the vendored CSS) and its own comment says the titlebar
_"keeps its own robijn fill — `--ro-layout` — untouched"_.
- `shell.component.ts` emits only `.layout`, `.main`, `.content`, `.skip` — page scaffolding.
- No `.nav` class appears in either, and neither carries a gap marker.
Both corrections are stated in the amended point 4 rather than silently dropped, so a reader
comparing the old text against the code can see which claim was retired and why.
## Replacement example chosen
`skeleton` and `spinner`, as the finding proposed. Both are in the gap register, both carry
markers reading "No loading-skeleton/spinner class in the vendored build", and both are
genuinely absent — the cleanest live illustration of the principle point 4 exists to state.
## Noted, not fixed: the gap register is still one row short
`grep` finds **9** `CIBG-GAP EXTENSION` markers; `libs/shared/docs/cibg-gaps.mdx` has **8**
rows. The missing one is `language-switcher`. That is **ADR-C-008 → RB-32** (batch 6), not
this ticket, and it was left alone.
@@ -0,0 +1,71 @@
# ADR-C-009 — state the runtime-editable-config exception as a test, not a list
Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009
· Gated on: **RB-07** (satisfied — batch 2)
## What was wrong
ADR-0004 said "never runtime-editable" and then named **one** exception in the singular,
justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second
runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the
equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate
idiom."_
The code is right; the ADR's text was wrong. A closed list of one leaves the next
operational-config surface with no principle to test itself against.
## What changed
| File | Change |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces |
| same file, §Context + the table | `src/locale/*.xlf``apps/<app>/src/locale/*.xlf` (two apps since WP-67) |
| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test |
No code changed — the finding says so outright, and verification confirmed it.
## Why the RB-07 gate was real, verified clause by clause
Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this
ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed,
so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of
the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok,
principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment
names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_
All four clauses were checked against both surfaces rather than assumed:
| Clause | `OrgTemplateStore` | `FeatureFlagStore` |
| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) |
| (2) fails closed | unknown `subOrgId``null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) |
| (3) operational | one sub-organisation's letterhead | an on/off rollout switch |
| (4) gated + audited | `OrgAdmin``orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin``flags:manage` (`Program.cs:914`) |
`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is
code … this store only holds the admin's on/off overrides. An unknown key is never
writable/enabled — the code catalog is the authority."_
## Judgement calls
- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a
deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:`
comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a
whole screen. That is a preview convenience on a read; the four write entry points all
return `null` for an unknown sub-org. The clause is worded "cannot invent a setting,
enable a feature, or be written" so this read fallback is not caught by it. Recorded
because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that
fallback first.
- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It
is stronger than the test requires, and making it a fifth clause would block a legitimate
flag-style surface that has nothing to version.
- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did
not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007
exist to correct, in the section being edited, and leaving a known-false path in a document
while amending it is the exact failure mode those two findings describe. Scope creep is
two words wide here; the alternative is filing a third ticket for it.
## Gate released
ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a
ticket can now be judged against a written test rather than by analogy to org-templates.
@@ -0,0 +1,57 @@
# RB-01 — authorize `GET /uploads/{id}/content` and `/uploads/status`
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-004 · `99-backlog.md` RB-01
## What was wrong
`GET /uploads/{documentId}/content` took `(string documentId)` — no `HttpContext`, so no
authorization was possible at all. It streams diploma and identity scans; the only
protection was the unguessability of the document GUID. `DELETE` on the same resource has
been owner-scoped (`DocumentStore.DeleteOwned`) since it was written.
`GET /uploads/status?localIds=` had the same shape, and leaks less but still confirms
whether a given client-chosen `localId` exists anywhere in the store, plus its documentId.
## What changed
| File | Change |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` |
| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` |
| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) |
| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases |
The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()`
**throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents
(`beoordeling-documenten.component.ts`) is a legitimate caller here:
```csharp
var allowed = ctx.Caller() switch
{
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
var caller => Authz.CanBeoordelen(caller),
};
```
**404, not 403**, per the ticket: a foreign document id must not be distinguishable from
one that never existed. `doc is null || !allowed` collapses both to the same answer, and
`/uploads/status` reports a foreign `localId` as `"unknown"` — the same word an id that
never existed gets.
## Known residual — this endpoint is reached without identity headers
Both callers link to the URL directly (`<a href>` in `beoordeling-documenten.component.ts`,
`previewUrl` in `libs/shared/src/upload/upload.adapter.ts`), so the request is a plain
browser navigation that carries **no** `X-Medewerker` / `X-Subject` header and never passes
through an Angular interceptor. `StubIdentityProvider` therefore resolves it to the seeded
citizen, which owns every document in the POC, so both links keep working — by coincidence,
not by authorization. That coincidence **is** BIO-002, and it is fixed by **RB-09** (making
`IIdentityProvider` able to express "no identity"), not here. RB-09 will need this endpoint
to receive a real credential — a signed URL or a cookie — rather than the ambient default.
## Verification
`dotnet build` clean. `dotnet test`: **250 passed, 1 failed** — the failure is
`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`,
which needs a live OpenZaak container and **fails identically on a stashed tree**, i.e. it
pre-dates this change.

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