feat(showcase): PII section — mask + elfproef parse (WP-42, mask/parse half)
Add a "PII — maskeren & parsen" section to /concepts demonstrating the WP-40 pieces with FP + atomic design, framed for AVG art. 9 / data-minimisation: a live <app-masked-value> atom (masked BSN that reveals on click; real reveal is step-up + audited in behandel-scherm) and a live parseBsn elfproef parse. Both show the real linked source via the WP-39 snippet mechanism (new showcase regions in bsn.ts + pii.ts, registered in gen-snippets.mjs). Delivers WP-42's showcase demo; the persisted-audit half stays pending WP-41. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,7 @@ Gates land before the work they cover; each lint rule lands in the same WP as th
|
|||||||
for its existing violations, so every WP ends green.
|
for its existing violations, so every WP ends green.
|
||||||
|
|
||||||
| WP | Title | Phase | Status |
|
| WP | Title | Phase | Status |
|
||||||
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ |
|
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------- |
|
||||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||||
@@ -86,7 +86,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
|
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | partial |
|
||||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
|
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
|
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
|
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
# WP-42 — Privacy & security showcase page
|
# WP-42 — Privacy & security showcase page
|
||||||
|
|
||||||
Status: todo
|
Status: partial — mask/parse showcase done; audit half pending WP-41
|
||||||
Phase: 8 — platform/DX/showcase
|
Phase: 8 — platform/DX/showcase
|
||||||
Priority: P2
|
Priority: P2
|
||||||
Depends on: WP-40, WP-41
|
Depends on: WP-40, WP-41
|
||||||
|
|
||||||
|
## Outcome (mask/parse half — done, on user request ahead of WP-41)
|
||||||
|
|
||||||
|
Added a "6 · PII — maskeren & parsen" section to `/concepts` demonstrating the WP-40 pieces with
|
||||||
|
FP + atomic design, framed for AVG art. 9 / data-minimisation: a live `<app-masked-value>` atom
|
||||||
|
(masked-by-default BSN that reveals on click; note points to the real step-up + audited reveal in
|
||||||
|
behandel-scherm) and a live `parseBsn` elfproef parse mirroring the postcode demo. Both show the
|
||||||
|
real linked source via the WP-39 snippet mechanism (`// #region showcase:parseBsn` in bsn.ts,
|
||||||
|
`showcase:mask` in pii.ts, registered in `gen-snippets.mjs` → `snippets.generated.ts`, drift-gated).
|
||||||
|
No i18n (showcase is Dutch-only teaching text). No behaviour change outside the showcase.
|
||||||
|
|
||||||
|
**Still pending (needs WP-41):** the "log PII / no-PII audit trail" half — visualizing the
|
||||||
|
persisted authz/reveal audit — plus an optional Foundations MDX writeup.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted
|
Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const SOURCES = [
|
|||||||
'src/app/registratie/domain/value-objects/postcode.ts',
|
'src/app/registratie/domain/value-objects/postcode.ts',
|
||||||
'src/app/registratie/domain/change-request.machine.ts',
|
'src/app/registratie/domain/change-request.machine.ts',
|
||||||
'src/app/herregistratie/domain/intake.machine.ts',
|
'src/app/herregistratie/domain/intake.machine.ts',
|
||||||
|
'src/app/shared/kernel/bsn.ts',
|
||||||
|
'src/app/shared/kernel/pii.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
const START = /\/\/\s*#region showcase:([\w-]+)/;
|
const START = /\/\/\s*#region showcase:([\w-]+)/;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type Bsn = Brand<string, 'Bsn'>;
|
|||||||
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11).
|
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11).
|
||||||
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
|
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
|
||||||
|
|
||||||
|
// #region showcase:parseBsn
|
||||||
export function parseBsn(raw: string): Result<string, Bsn> {
|
export function parseBsn(raw: string): Result<string, Bsn> {
|
||||||
const t = raw.trim();
|
const t = raw.trim();
|
||||||
if (!/^\d{9}$/.test(t)) {
|
if (!/^\d{9}$/.test(t)) {
|
||||||
@@ -22,5 +23,6 @@ export function parseBsn(raw: string): Result<string, Bsn> {
|
|||||||
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
|
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ok(t as Bsn);
|
return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
|
||||||
}
|
}
|
||||||
|
// #endregion showcase:parseBsn
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
export const REDACTED = '‹redacted›';
|
export const REDACTED = '‹redacted›';
|
||||||
|
|
||||||
|
// #region showcase:mask
|
||||||
/** Keep the last `keep` characters, mask the rest with `*`. */
|
/** Keep the last `keep` characters, mask the rest with `*`. */
|
||||||
export function maskTail(value: string, keep: number): string {
|
export function maskTail(value: string, keep: number): string {
|
||||||
if (value.length <= keep) return '*'.repeat(value.length);
|
if (value.length <= keep) return '*'.repeat(value.length);
|
||||||
@@ -15,3 +16,4 @@ export function maskTail(value: string, keep: number): string {
|
|||||||
export function maskBsn(value: string): string {
|
export function maskBsn(value: string): string {
|
||||||
return maskTail(value, 3);
|
return maskTail(value, 3);
|
||||||
}
|
}
|
||||||
|
// #endregion showcase:mask
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
|||||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||||
import { Registration } from '@registratie/domain/registration';
|
import { Registration } from '@registratie/domain/registration';
|
||||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
|
import { parseBsn } from '@shared/kernel/bsn';
|
||||||
|
import { maskBsn } from '@shared/kernel/pii';
|
||||||
|
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||||
import { SNIPPETS } from './snippets.generated';
|
import { SNIPPETS } from './snippets.generated';
|
||||||
import { highlightTs } from './highlight-ts';
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
RegistrationSummaryComponent,
|
RegistrationSummaryComponent,
|
||||||
HerregistratieWizardComponent,
|
HerregistratieWizardComponent,
|
||||||
IntakeWizardComponent,
|
IntakeWizardComponent,
|
||||||
|
MaskedValueComponent,
|
||||||
],
|
],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -371,6 +375,65 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 6. PII: mask + parse -->
|
||||||
|
<section class="section">
|
||||||
|
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||||
|
<p class="lead">
|
||||||
|
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
|
||||||
|
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
|
||||||
|
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||||
|
</p>
|
||||||
|
<div class="cols">
|
||||||
|
<div class="card card--good">
|
||||||
|
<p class="tag good">Maskeren — atom</p>
|
||||||
|
<p>
|
||||||
|
BSN:
|
||||||
|
<app-masked-value
|
||||||
|
[value]="bsnShown()"
|
||||||
|
[canReveal]="true"
|
||||||
|
revealLabel="Toon BSN"
|
||||||
|
(reveal)="bsnRevealed.set(true)"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['mask']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
<p class="note">
|
||||||
|
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||||
|
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||||
|
bij elke gebruiker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="tag good">Parse (elfproef) → Result</p>
|
||||||
|
<app-text-input
|
||||||
|
inputId="bsn"
|
||||||
|
[ngModel]="bsnRaw()"
|
||||||
|
(ngModelChange)="bsnRaw.set($event)"
|
||||||
|
name="bsn"
|
||||||
|
placeholder="Typ een BSN, bijv. 123456782"
|
||||||
|
/>
|
||||||
|
@let b = bsnParsed();
|
||||||
|
@if (bsnRaw()) {
|
||||||
|
<div animate.enter="app-item-enter">
|
||||||
|
@if (b.ok) {
|
||||||
|
<p class="tag good">ok</p>
|
||||||
|
<pre>Bsn ="{{ b.value }}"</pre>
|
||||||
|
} @else {
|
||||||
|
<p class="tag bad">err</p>
|
||||||
|
<pre>{{ b.error }}</pre>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['parseBsn']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -394,6 +457,14 @@ export class ConceptsPage {
|
|||||||
raw = signal('');
|
raw = signal('');
|
||||||
parsed = computed(() => parsePostcode(this.raw()));
|
parsed = computed(() => parsePostcode(this.raw()));
|
||||||
|
|
||||||
|
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
|
||||||
|
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||||
|
demoBsn = '123456782';
|
||||||
|
bsnRevealed = signal(false);
|
||||||
|
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||||
|
bsnRaw = signal('');
|
||||||
|
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||||
|
|
||||||
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
||||||
private readonly illustrations: Record<string, string> = {
|
private readonly illustrations: Record<string, string> = {
|
||||||
unionBad: `interface Registration {
|
unionBad: `interface Registration {
|
||||||
@@ -419,5 +490,7 @@ errors = signal<...>({});
|
|||||||
parse: 'registratie/domain/value-objects/postcode.ts',
|
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||||
machine: 'registratie/domain/change-request.machine.ts',
|
machine: 'registratie/domain/change-request.machine.ts',
|
||||||
steps: 'herregistratie/domain/intake.machine.ts',
|
steps: 'herregistratie/domain/intake.machine.ts',
|
||||||
|
parseBsn: 'shared/kernel/bsn.ts',
|
||||||
|
mask: 'shared/kernel/pii.ts',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
export const SNIPPETS: Record<string, string> = {
|
export const SNIPPETS: Record<string, string> = {
|
||||||
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
|
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
|
||||||
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
|
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
|
||||||
|
"mask": "/** Keep the last `keep` characters, mask the rest with `*`. */\nexport function maskTail(value: string, keep: number): string {\n if (value.length <= keep) return '*'.repeat(value.length);\n return '*'.repeat(value.length - keep) + value.slice(-keep);\n}\n\n/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */\nexport function maskBsn(value: string): string {\n return maskTail(value, 3);\n}",
|
||||||
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
|
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
|
||||||
|
"parseBsn": "export function parseBsn(raw: string): Result<string, Bsn> {\n const t = raw.trim();\n if (!/^\\d{9}$/.test(t)) {\n return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);\n }\n const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);\n if (t === '000000000' || sum % 11 !== 0) {\n return err(\n $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,\n );\n }\n return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef\n}",
|
||||||
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
|
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
|
||||||
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
|
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user