# 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 present - `org-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 in `org-template.adapter.ts` — both are already separate functions and only need `export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading the file: the other two adapters do have a standalone `errorMessage`/similar function, but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the `try { … } 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 named `proefbriefErrorMessage`, 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`'s `onReveal()` already gates the _only_ path that can reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own docstring says the step-up gesture "is the UI's concern". Threading a boolean through the component's `output()` 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 gating `currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around the headers object is not strictly load-bearing for `X-Subject` (already `undefined` outside dev) and only changes the _value sent_ for `X-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 — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern — rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it. - **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via `inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient` purely to reach a method that doesn't use either. I judged that disproportionate to the marginal coverage gained, since the identical `isDevMode()` pattern is already exercised end-to-end (via `fetch` stubbing) on the other two adapters (`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the underlying reader-level fix is covered directly in `role.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 from `debug-state.component.ts`, which is itself rendered only under `shell.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 under `isDevMode()`" line is accurate as of this commit — the gate now lives in the interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves. Before this commit, the sentence was false for the three hand-written `fetch` paths; 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 `fetch` adapters specifically (the accompanying sentence already says they "bypass the interceptor", which stays true — they still don't go through `HttpClient`). 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 in `roles-and-access.md`.** The BIO-012 evidence and this ticket's brief both discuss it, but the doc file never named `?subject=`/`X-Subject` before 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-Rollen` parallel) 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.mdx` is 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 new `describe` blocks 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 the `if (!isDevMode())` guard from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured, still written to `sessionStorage`). - `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()` turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written to `sessionStorage`). - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting `reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }` turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the `stepUp` argument; `X-Role` sent regardless of `isDevMode()`). 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).