Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected
Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-012,
TE-002, BIO-006(a)+(b) · 99-backlog.md RB-11
What was wrong
BIO-012 — the dev hatches were not actually dev-only. The roleInterceptor /
subjectInterceptor chain is correctly registered only under isDevMode()
(app.config.ts), but three adapters bypass HttpClient entirely and set headers
themselves with no guard at all:
reveal-bignummer.adapter.ts:26—'X-Role': currentRole(), 'X-Step-Up': 'true'letter-preview.adapter.ts:46—'X-Role': currentRole(), plus'X-Subject'when presentorg-template.adapter.ts:79—'X-Role': currentRole()
The readers underneath were ungated too: role.ts:24 and subject.ts:24 both read the
?role=/?subject= query param and wrote it into sessionStorage on any
navigation, in any build. For ?subject= that value is a BSN — subject.ts's own doc
comment argued at length that the BSN must never leave SessionStore and then routed it
through sessionStorage anyway. docs/reference/roles-and-access.md:23 claimed "Both
are wired only under isDevMode() — they do not exist in a production build", which was
false for exactly these three call sites.
TE-002 — the reveal's trust boundary was not callable. The response-shape validation
in reveal-bignummer.adapter.ts (the code's own comment called it a "Trust boundary")
lived inline inside async reveal(), after await fetch(...) on the global fetch. A
spec could not reach it without stubbing globalThis.fetch. The same shape recurred,
un-exported, in letter-preview.adapter.ts's errorMessage and — contrary to the
finding's text, see "Judgement calls" below — as an inline try/catch (not yet a
function) in org-template.adapter.ts's proefbrief().
BIO-006(a) — the step-up stub was a constant. reveal-bignummer.adapter.ts sent
'X-Step-Up': 'true' unconditionally, as a literal, so the backend's
canReveal && X-Step-Up == "true" precondition was satisfied by every call that reached
the endpoint and constrained nothing.
BIO-006(b) — the default role holds the PII-reveal capability, undocumented.
StubIdentityProvider's _ => role-switch arm resolves any request with no (or an
unrecognised) X-Role header to drafter — the one role Authz.CanRevealBigNummer
grants. roles-and-access.md documented drafter as "the only role that may reveal a
BSN" without noting that it is also the fallback identity, so the least-privilege
consequence was invisible.
What changed
| File | Change |
|---|---|
libs/shared/src/infrastructure/role.ts |
currentRole() returns 'drafter' immediately when !isDevMode() — no query-param read, no sessionStorage write |
libs/shared/src/infrastructure/subject.ts |
currentSubject() returns undefined immediately when !isDevMode() — same treatment for the BSN |
reveal-bignummer.adapter.ts |
reveal(stepUp: boolean); X-Role sent only under isDevMode(), X-Step-Up sent only when stepUp; inline shape check moved to exported parseRevealed(body); REVEAL_FAILED exported |
letter-preview.adapter.ts |
headers wrapped in isDevMode() ? {...} : {}; errorMessage exported |
org-template.adapter.ts |
X-Role sent only under isDevMode(); the inline proefbrief() error try/catch extracted to an exported proefbriefErrorMessage; PROEFBRIEF_FAILED exported |
apps/ssp/src/app/brief/application/brief.store.ts |
revealBigNummer() calls this.revealAdapter.reveal(true) — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter |
docs/reference/roles-and-access.md |
records which two places the isDevMode() gate now lives (interceptor registration and the reader functions) and why; adds the BIO-006(b) note that drafter is also the backend's fallback identity |
6 new/extended *.spec.ts |
see "Verification" below |
The fix is two layers, not one, because the finding named both. Gating only
currentRole()/currentSubject() would already stop the query param and the
sessionStorage write from working outside isDevMode() — the three adapters would
then send the safe default ('X-Role': 'drafter', no X-Subject) even in production.
The adapters are also wrapped in isDevMode() so a production request from any of the
three hand-written fetch calls carries no X-Role/X-Subject header at all, exactly
matching what a HttpClient request already does once roleInterceptor is not
registered — the two paths now agree on production behaviour instead of merely agreeing
on the resulting header value.
X-Step-Up is deliberately not folded into the same isDevMode() gate. It is not a
?role=/?subject=-style dev override; it is the stub for a control BIO-006 says must
survive into production (in stubbed form) until a real step-up exists. Nesting it inside
isDevMode() would make the reveal endpoint permanently unreachable in a production
build. Instead it is gated on the stepUp parameter alone, which is true only when
BriefStore.revealBigNummer() — reachable only via behandel-scherm.component.ts's
onReveal() confirm — calls it.
Judgement calls
org-template.adapter.ts's proefbrief error mapping was not "already a separate function". The finding's remediation text says "the proefbrief error mapping inorg-template.adapter.ts— both are already separate functions and only needexportand a spec", matchingletter-preview.adapter.ts'serrorMessage. Reading the file: the other two adapters do have a standaloneerrorMessage/similar function, butorg-template.adapter.ts's proefbrief error handling was inlined directly in thetry { … } catch { … }block, not a named function. This is a minor factual imprecision in the finding, not a blocker — I extracted the same inline logic into a namedproefbriefErrorMessage, exported it, and added the same spec shape as its two siblings. The result matches the finding's intent (a callable, spec'd trust boundary) even though the starting shape needed one extra step the finding didn't mention.- The BIO-006(a) literal moved to
BriefStore.revealBigNummer(), not to the UI.behandel-scherm.component.ts'sonReveal()already gates the only path that can reachstore.revealBigNummer()behind aconfirm()dialog, and the store's own docstring says the step-up gesture "is the UI's concern". Threading a boolean through the component'soutput<void>()and the page's template binding would touch three more files for no behavioural change, since the call graph already guarantees confirmation happened first. I moved the literal one layer up instead — out of the adapter (the transport) and into the store (the command that is exclusively reachable via the confirmed gesture) — which is the smallest change consistent with "not from the adapter's literal" and with this repo's ui → application → infrastructure layering (ui cannot call infrastructure directly to pass the flag down any other way). - Redundant-looking
isDevMode()guards, kept anyway. After gatingcurrentRole()/currentSubject(), the three adapters' ownisDevMode()wrap around the headers object is not strictly load-bearing forX-Subject(alreadyundefinedoutside dev) and only changes the value sent forX-Role(a hardcoded'drafter'vs. no header) rather than any security outcome (the backend treats both identically). I kept the adapter-level gate anyway so the security posture is visible by inspection at the fetch call site — matchingapp.config.ts'sisDevMode() ? [...] : []pattern — rather than requiring a reviewer to trace intorole.ts/subject.tsto confirm it. - No
proefbrief()-level header spec.OrgTemplateAdapterinjectsApiClientviainject(), so exercisingproefbrief()itself needs aTestBed+ a mockApiClientpurely to reach a method that doesn't use either. I judged that disproportionate to the marginal coverage gained, since the identicalisDevMode()pattern is already exercised end-to-end (viafetchstubbing) on the other two adapters (reveal-bignummer.adapter.spec.ts,letter-preview.adapter.spec.ts), and the underlying reader-level fix is covered directly inrole.spec.ts. Noted here as a residual rather than silently skipped. setRole()(the dev-switcher writer) was left ungated. BIO-012's evidence names the two readers (currentRole/currentSubject);setRole()is only ever invoked fromdebug-state.component.ts, which is itself rendered only undershell.component.ts's@if (isDev && debugPanel). Gating it too would be harmless but wasn't asked for and has no reachable production call site to protect — left alone to keep the diff to what the finding actually named.
Consequences worth knowing
- Doc correction, same diff.
roles-and-access.md's "Both are wired only underisDevMode()" line is accurate as of this commit — the gate now lives in the interceptor registration and insidecurrentRole()/currentSubject()themselves. Before this commit, the sentence was false for the three hand-writtenfetchpaths; the doc has been extended, not merely left as-is, to say where the gate lives so a future reader doesn't have to rediscover why the interceptor site alone wasn't sufficient. - CLAUDE.md needed no correction. Its "Scenario toggle (dev-only, not wired in prod
builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three
hand-written
fetchadapters specifically (the accompanying sentence already says they "bypass the interceptor", which stays true — they still don't go throughHttpClient). Those claims were already compatible with a fix landing here; they made no false statement that needed walking back. ?subject=is still undocumented by name inroles-and-access.md. The BIO-012 evidence and this ticket's brief both discuss it, but the doc file never named?subject=/X-Subjectbefore this change and still doesn't get a dedicated section — only the new paragraph under "How to switch role" mentions it in passing. A full?subject=write-up (its own e2e-only purpose,X-Medewerker/X-Rollenparallel) is arguably worth a follow-up doc pass, but out of scope for a security-focused ticket about production leakage.- Behaviour spec regenerated.
libs/shared/docs/behaviour-spec.mdxis generated from the suite (npm run gen:behaviour-spec) and is included in this diff — the CI gate's drift check would otherwise fail on the 6 newdescribeblocks this ticket adds.
Verification
Every fix below was confirmed red without it by temporarily reverting the source change (tests unchanged) and re-running the affected suite, then restoring the fix:
libs/shared/src/infrastructure/role.spec.ts— removing theif (!isDevMode())guard fromcurrentRole()turned 3 "outside isDevMode()" tests red (?role=still honoured, still written tosessionStorage).libs/shared/src/infrastructure/subject.spec.ts— same removal oncurrentSubject()turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written tosessionStorage).apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts— revertingreveal()to the original unconditional{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }turned bothRevealBigNummerAdapter.revealtests red (X-Step-Upsent regardless of thestepUpargument;X-Rolesent regardless ofisDevMode()).
New specs, all pure/exported-boundary tests per house convention (no TestBed, no
globalThis.fetch stub needed for the pure halves):
| File | Covers |
|---|---|
reveal-bignummer.adapter.spec.ts |
parseRevealed (incl. the finding's own { bigNummer: 42 } rejection case); reveal()'s X-Step-Up/X-Role gating via a stubbed fetch |
letter-preview.adapter.spec.ts |
errorMessage; preview()'s header gating via a stubbed fetch |
org-template.adapter.spec.ts (extended) |
proefbriefErrorMessage |
role.spec.ts (new) |
currentRole() dev behaviour + the isDevMode()-gated production behaviour |
subject.spec.ts (new) |
currentSubject() dev behaviour + the isDevMode()-gated production behaviour |
npm run ci (lint, typecheck, dep:check, format:check, check:tokens, check:seam,
full test suite with coverage, ng build --localize for both apps, npm audit, backend
dotnet format + dotnet test, showcase-snippets/behaviour-spec/api-client drift
checks): green, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and
dotnet test (241 passed).