Step 3 (production-readiness): PII storage, validated reads, seams

Implement-now:
- G1: keep PII out of persistent storage — never persist BSN (only `naam`);
  move both wizard drafts (address/email, work data) localStorage → sessionStorage
  so they clear on tab close.
- G2: validate storage reads before trusting the cast — shape/tag guard in every
  restore() (mirrors the parse* HTTP boundary); corrupt/foreign shape → start fresh.
- G3: already satisfied (debug-state redacts via mask.ts).

Show-the-seam (hook + doc, not fully built):
- G4: problemFieldErrors() maps a server validation envelope (ASP.NET
  ValidationProblemDetails `errors`) to the field-keyed map the wizards already
  render; returns {} until the backend sends it. +spec.
- G5: documented the retry/backoff seam at the adapter GET loader; reads may
  retry, mutating submits never do.

Out of scope (named): unsaved-changes warning (persistence prevents data loss),
real auth/tokens, axe-core in CI.

Gate green: lint, check:tokens, build, test 79/79.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-06-27 14:07:10 +02:00
co-authored by Claude Opus 4.8
parent 474c040410
commit 9c2a80451f
6 changed files with 69 additions and 18 deletions
@@ -31,7 +31,7 @@ const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted s
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored — so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to
localStorage so a page reload keeps the user's progress. */
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
@Component({
selector: 'app-intake-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
@@ -169,14 +169,15 @@ export class IntakeWizardComponent {
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories) wins; otherwise resume from localStorage.
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
const seeded = this.seed();
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
// Persist only while answering; clear once the flow is done.
// Persist only while answering; clear once the flow is done. G1: sessionStorage
// (not localStorage) — the draft holds personal data and must not outlive the tab.
effect(() => {
const s = this.state();
if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
if (s.tag === 'Answering') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else sessionStorage.removeItem(STORAGE_KEY);
});
// Apply the server-owned threshold into machine state as it arrives. Track
// only the policy value; untrack the dispatch (it reads the state signal
@@ -188,10 +189,11 @@ export class IntakeWizardComponent {
}
private restore(): IntakeState | null {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as IntakeState;
const parsed = JSON.parse(raw) as IntakeState;
return parsed?.tag === 'Answering' ? parsed : null; // G2: only resume a known shape.
} catch {
return null; // ponytail: corrupt entry -> start fresh, no migration.
}