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>
18 KiB
RB-13 — land Session → Principal; MedewerkerAdapter; the backoffice login stops being a DigiD/BSN form
Status: implemented · 2026-08-27 · Source findings: 06-adr-conformance.md ADR-C-004 · 00-baseline.md BL-002 · docs/reference/architecture/0002-user-groups-and-bounded-contexts.md §3, "Known debt" · 99-backlog.md RB-13
What was wrong
ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated
Principal union — { kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker'; medewerkerId; naam; rollen } — as "the one concrete FE change when actor #2 lands."
Actor #2 (apps/behandelportal) landed in WP-61/67; the union did not follow.
Verified before this ticket:
grep -rn "Principal" apps libsreturned exactly one hit — a comment inlibs/shared/src/infrastructure/role.ts:8. No such type existed.apps/ssp/src/app/auth/domain/session.tsandapps/behandelportal/src/app/auth/domain/session.tswere byte-identical:interface Session { readonly bsn: string; readonly naam: string }— a Behandelaar carrying absn, which §3 names as precisely the state the union exists to make unrepresentable.apps/behandelportal/src/app/auth/ui/login.page.tsrenderedintro="Log in op uw persoonlijke BIG-register omgeving."and calledSessionStore.login(bsn)→DigidAdapter.authenticate(bsn), resolving{ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }— a backoffice employee logging into the backoffice as a citizen, by DigiD, under a citizen's name.apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.tsalready stamps every backend request withX-Medewerker/X-Rollen, independently ofSessionStore— the divergence ADR-0002 predicted took this orthogonal side door instead of thePrincipalunion, which is why the twoauthcontexts still measured as identical.tools/baseline-scan.mjs --dup, measured immediately before this ticket (after ADR-C-006 shared the route guards):ssp/auth168/168 dup lines (100.0%),bhp/auth168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's "auth stays duplicated because it's expected to diverge" claim had never actually been tested, only asserted.
RB-09 (a prerequisite, landed the day before) made the backend's IIdentityProvider
able to say "no identity" and fail closed; this ticket is its stated FE half — without
it, a production behandelportal falls through to the seeded zorgverlener by default,
open on every citizen-scoped endpoint and holding CanRevealBigNummer. This ticket
does not touch that backend behaviour — it makes the FE identity model honest about
who is actually authenticating.
What changed
| File | Change |
|---|---|
apps/ssp/src/app/auth/domain/session.ts → principal.ts |
Session → Principal, { kind: 'zorgverlener'; bsn; naam }; parseStoredSession → parseStoredPrincipal (G1/G2 unchanged) |
apps/ssp/src/app/auth/domain/session.spec.ts → principal.spec.ts |
renamed, updated to the Principal/kind shape |
apps/ssp/src/app/auth/application/session.store.ts |
Session → Principal; header doc rewritten to state why G1 applies here and not in behandelportal (cross-reference, not shared prose) |
apps/ssp/src/app/auth/infrastructure/digid.adapter.ts |
resolves { kind: 'zorgverlener', bsn, naam } |
apps/ssp/src/app/shell/debug-state/debug-state.component.ts |
Session → Principal (the one other consumer of the domain type) |
apps/behandelportal/src/app/auth/domain/session.ts → principal.ts |
new medewerker variant: { kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }; parseStoredPrincipal validates the full shape (no BSN to strip — G2 only); new parseRollen(raw): Rol[], mirroring the backend's StubIdentityProvider.ParseRollen (comma-separated, case-insensitive, unrecognized tokens dropped) |
apps/behandelportal/src/app/auth/domain/session.spec.ts → principal.spec.ts |
rewritten: isAuthenticated, parseStoredPrincipal (5 cases including "kind is not medewerker" and "unrecognized rol"), parseRollen (4 cases) |
apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts → medewerker.adapter.ts |
new MedewerkerAdapter — resolves MEDEWERKER_ID + currentRollen() (medewerker.ts, unchanged) into a Principal; no input, returns the Principal directly (no Result — there is nothing for this stand-in to fail on) |
apps/behandelportal/src/app/auth/application/session.store.ts |
MedewerkerAdapter replaces DigidAdapter; login() takes no argument; the whole principal round-trips through localStorage (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it |
apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts |
rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, submitted = output<void>() |
apps/behandelportal/src/app/auth/ui/login.page.ts |
new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); login() takes no argument; the error-alert branch is gone (nothing can fail) |
apps/behandelportal/src/locale/messages.en.xlf |
new id login.ssoExplainer; login.submit/login.heading/login.intro updated to the new source text + English target; login.bsnLabel/bsnDescription/wachtwoordLabel/form.verplichteVelden removed (no longer reachable from this app — confirmed by grep and by a trial extract-i18n:behandelportal run) |
libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts |
doc comments: `Session.bsn` → `Principal.bsn` (the type these comments cite renamed; the design they describe — libs/shared can't reach an app-local auth context, so ?subject= exists instead — is unchanged) |
docs/reference/architecture/0002-user-groups-and-bounded-contexts.md |
new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure |
libs/shared/docs/behaviour-spec.mdx |
regenerated (npm run gen:behaviour-spec) — reflects the renamed spec titles and the new parseRollen/medewerker parseStoredPrincipal cases |
Judgement calls
- Each app's
Principalholds only the one variant it has an actor for, not the full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own proposed resolution under ADR-C-004 says this explicitly ("Inapps/behandelportal: replaceSessionwith themedewerkervariant … Inapps/ssp: thezorgverlenervariant"), and it matches how the codebase already splitsauthper app.kindstays on both single-member types anyway — it is what makes the two types genuinely different rather than a same-shaped coincidence, and it is where a third actor (§4 — admin/auditor/institution-rep) would add a member. MedewerkerAdapter.authenticate()returnsPromise<Principal>, notPromise<Result<string, Principal>>. The first draft mirroredDigidAdapter'sResult-returning shape for symmetry, but thatResult's error variant could never actually be produced — there is no credential to check, so wrapping the return in a type that claims to have a failure mode was itself a small instance of the thing CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a directPromise<Principal>and dropped the now-dead error-handling branch fromlogin.page.ts(errorsignal, the<app-alert type="error">, theAlertComponentimport) — a real SSO integration is where that branch would come back, not before. This was also the change that did the most to bring the duplication figure down (see below):login.page.ts's 7-window overlap with the SSP's disappeared once the two pages' control flow, not just their copy, actually differed.rollenis typedreadonly Rol[]withRol = 'behandelaar', andparseRollenlives indomain/, not the adapter. The rawcurrentRollen()stand-in returns an unvalidated string (medewerker.ts, untouched by this ticket); turning it into typedRol[]is pure string logic with no Angular dependency, so it belongs indomain/principal.tsper CLAUDE.md §1's layer table — the adapter (infrastructure/) stays a thin wire-up that only reaches forMEDEWERKER_ID/currentRollen()and hands them to a pure function.parseRollendeliberately mirrors the backend's ownStubIdentityProvider.ParseRollen(comma-separated, unrecognized tokens dropped, so?rollen=geenyields[]) — this is not the FE recomputing a business rule (ADR-0001's boundary is about authorization decisions, which still come only fromGET /me/AccessStore); it is the FE's own dev-only identity stand-in echoing the same header value it is about to send, for display, the same wayDigidAdapteralready fabricates its own fake identity.SessionStore(bhp) persists the wholePrincipaltolocalStorage, not a stripped-down{ naam }copy. The SSP's G1 guarantee ("never persist the BSN") doesn't apply here — amedewerkerprincipal has no national identifier — so there is nothing to strip.parseStoredPrincipalvalidates the full shape (G2 only) and restores it as-is. This was a deliberate choice against an alternative: reconstructingmedewerkerId/rollenfrom the liveMEDEWERKER_ID/currentRollen()on every restore, which would have madedomain/principal.tsdepend oninfrastructure/medewerker.ts— backwards per CLAUDE.md §1's inward-only dependency rule, and it would have madeparseStoredPrincipalimpure. Consequence: changing?rollen=mid-session does not retroactively change an already-restoredPrincipaluntil the nextlogin()/logout()— the same way changing the DigiD demo BSN requires a fresh login in the SSP. The backend's own authorization is unaffected either way, sincemedewerkerInterceptorreadscurrentRollen()fresh on every HTTP request regardless of whatSessionStoreholds.- Session/store class names (
SessionStore,SESSION_PORT,SessionPort) were left unchanged. ADR-0002 §3's own Consequences section namesSessionStore— alongsideauth.guard.ts— as one of the seams that localise theSession → Principalchange, not as something the change renames.libs/shared/src/application/session.port.ts'sSessionPort(ADR-C-006) is unaffected: it only ever exposed{ naam }andisAuthenticated, neither of which iskind-dependent. libs/shared/src/infrastructure/subject.ts/subject.interceptor.tsdoc comments updated, code untouched. Both cite`Session.bsn`by name to explain why?subject=exists instead of reading the store directly; renaming the type these comments describe without updating the comment would have left them citing a type that no longer exists.auth.guard.ts's verbatim re-export in both apps was left alone. ADR-C-006 is explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it reads onlySESSION_PORT/AccessStore, neverPrincipal, so there was nothing for this ticket to change there.- No backend change. RB-09 already made
IIdentityProvidernullable and Production-fail-fast; this ticket is purely the frontend counterpart it named. The residual RB-09 flagged (GET /uploads/{documentId}/content's plain-navigation callers carrying no identity header once a real, non-stubIIdentityProviderexists) is unaffected by anything here — it is about a future real provider replacing the Development-only stub, which this ticket does not touch.
Duplication, measured (tools/baseline-scan.mjs --dup)
| When | ssp/auth dup lines |
bhp/auth dup lines |
|---|---|---|
| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — |
| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) |
| After this ticket | 32/179 (17.9%) | 32/259 (12.4%) |
Expected by the backlog: "<40 after this." Measured: 32 lines each side — under target. The full clone-pair listing (the script's own output truncates to the top 15 pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was hiding below that cut) resolves to exactly four remaining pairs:
principal.spec.ts(6 windows) — both files test the same G2 "validate before trusting a stored shape" concept with a paralleldescribe/itstructure (including the sharedimport { describe, it, expect } from 'vitest';line); the assertions themselves differ (BSN-stripping vs. kind/rollen validation).login-form.stories.ts(3 windows) — the generic StorybookMeta/StoryObj/Defaultscaffold, unavoidable for any two co-located.stories.tsfiles regardless of subject.auth.guard.ts(2 windows) — the intentional verbatim re-export (ADR-C-006); this is meant to stay identical.session.store.ts(1 window) — down from 33 windows before this ticket to one small shared fragment (the@Injectable/signal/asReadonly/computedwiring any root singleton store in this codebase shares).
None of what remains is re-converged identity or login-flow logic — the domain type, the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction ("the two groups authenticate differently") has been tested for the first time by this ticket, not just asserted, and it held.
Verification
Confirmed each non-trivial change is red without its fix (edited in place, verified red,
edited back — never git checkout):
- ssp
parseStoredPrincipal(G1): changedbsn: ''tobsn: parsed.bsn ?? ''→G1: a stored bsn is never restored…failed withexpected { bsn: '19012345601', …} to deeply equal { bsn: '', … }. Reverted; all other tests unaffected. - bhp
parseStoredPrincipal(kind guard): dropped theparsed?.kind === 'medewerker'clause →returns null when kind is not medewerkerfailed, returning the parsed zorgverlener-shaped object instead ofnull. Reverted. - bhp
parseRollen: dropped.filter(isRol)→drops unrecognized tokensandreturns an empty list for an empty stringboth failed (['geen']/['']returned instead of[]). Reverted.
npm test (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) +
133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs.
npm run lint: clean. npm run dep:check: 0 violations, both apps. ng build ssp --localize and ng build behandelportal --localize: both succeed (the new
login.ssoExplainer id and the updated login.submit/login.heading/login.intro
sources all resolve to an English <target>). npm run ci: green (see the commit this
doc ships with).