diff --git a/CLAUDE.md b/CLAUDE.md index f9aaee4..0bb3f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,10 +61,15 @@ Environments: `src/environments/environment.ts` (`useMocks:false`), `environment policy-editor-page}`; darunter `policy-editor-page/policy-builder/` (**Kern-Feature**, Wizard mit `components/`, `metadata/`, `validators/`, `helpers/`). - `ui/` — wiederverwendbares Design-System auf Angular Material (`con-x-*`-Komponenten). -- `services/` — u.a. `services/policies/policy.service.ts` (HTTP-CRUD) und - `services/policies/policy-mapper/policy-odrl.mapper.ts` (Domänenmodell → ODRL/EDC). +- `services/` — u.a. `services/policies/policy.service.ts` (HTTP-CRUD), + `services/policies/policy-mapper/policy-odrl.mapper.ts` (Domänenmodell → ODRL/EDC) und + `services/http/` (`http-error.interceptor.ts` loggt fehlgeschlagene Requests, + `http-error.helper.ts` wählt den i18n-Key nach Statuscode). - `shared/` — `types/` (Modelle), `pipes/`, `adapters/`. - `mocks/` — MirageJS-Server + Mock-Daten. +- `src/fonts/` — selbst gehostete Schriften (Montserrat, Material Icons), eingebunden über + `src/styles/_fonts.scss`. **Keine** Google-Fonts-Links in `index.html` (Herkunft & Lizenzen: + `src/fonts/README.md`). Routing ist **lazy** (`app.routes.ts`). **Pfad-Aliase** statt Relativimporte: `@pages/* @ui/* @services/* @shared/* @mocks/* @env @features/policies/builder/*`. @@ -127,6 +132,37 @@ Zielniveau **WCAG 2.2 AA** (Grundausstattung). Details & Restrisiken: `docs/acce - **Prüfung:** `templateAccessibility`-Regeln laufen in `npm run lint`; ergänzend manuell (Tastatur, NVDA, Zoom, axe/Lighthouse). +### Sicherheits-Konventionen + +Aus dem OWASP-Top-10-Audit abgeleitet; bitte einhalten, sonst kommen die Befunde zurück. + +- **Ein HTTP-Response ist Eingabe.** `http.get()` prüft zur Laufzeit nichts — der Typ + ist ein Compile-Zeit-Versprechen. Daten aus der API vor der Verwendung validieren: + unbekannte Constraint-Typen über `keepKnownConstraints()` / `isKnownConstraintType()` + aussortieren (ein Index-Zugriff mit unbekanntem Typ liefert `undefined` und wirft beim + Rendern). +- **Nie ungeprüfte Werte als Transloco-Parameter.** Transloco durchsucht das Ergebnis einer + Ersetzung erneut nach Platzhaltern; ein Wert mit `{{…}}` wird ein zweites Mal aufgelöst und + kann die Schleife zum Nicht-Terminieren bringen. Werte gegen die Metadaten-Registry prüfen + (`USE_CASE_OPTIONS`, `FRAMEWORK_AGREEMENT_VALUE`), sonst `'—'`. +- **i18n-Keys nie aus Daten zusammensetzen** — immer aus der Registry entnehmen. +- **`encodeURIComponent` für jeden Wert in einer URL,** auch für Route-Parameter: Angular + dekodiert `%2F`/`%2E`, der Browser normalisiert anschließend `..` im Pfad. +- **`policyId` ist ein Identifier** (wird zum JSON-LD-`@id`) — Zeichensatz-Validierung in + `constraint-validators.ts` beibehalten. +- **HTTP-Fehler nie verwerfen:** `error: (err: unknown) => …` mit + `httpErrorMessageKey(err, '')`. Nie Request-/Response-Bodies loggen. +- **Dev-Werkzeug gehört nicht ins Prod-Bundle.** `@if` steuert nur das Rendern; das Bundling + entscheiden die `import`-Statements. Neue Mock-/Debug-Komponenten über `fileReplacements` + ausschließen — dabei eine **Barrel-Datei** ersetzen, nicht die Komponente selbst + (Angular behält die `templateUrl`-Zuordnung am Pfad, sonst bricht der AOT-Build mit + `TS2339` ab; Muster: `ui/mock-data-switcher/index.ts`). +- **Keine externen Laufzeit-Ressourcen** (Fonts, Skripte, Styles von fremden Domains) — + sie übertragen die Nutzer-IP und machen Builds vom Netz abhängig. +- **Produktion baut mit `security.autoCsp` und `subresourceIntegrity`.** Wer an Critical-CSS + oder `index.html` schraubt, prüft das Ergebnis **im Browser** gegen den Prod-Build + (Stylesheet aktiv? CSP-Violations?), nicht nur im HTML-Quelltext. + ### Architektur-Entscheidungen - **Signal-first State** — kein externes State-Management. - **Trennung Domänenmodell ↔ externes ODRL/EDC-Format.** Die Übersetzung ist isoliert im @@ -143,7 +179,8 @@ Zielniveau **WCAG 2.2 AA** (Grundausstattung). Details & Restrisiken: `docs/acce startet den Mock-Server selbst (via `start-server-and-test`), wartet auf `:4200`, fährt Cypress headless und stoppt danach. Deckt die **Hauptflüsse** gegen den Mock-Modus ab: Policy erstellen, ansehen, bearbeiten, löschen, Liste durchsuchen/filtern; inkl. empty-/no-results-States. Specs in - `cypress/e2e/*.cy.ts`. (`npm run cy:run` fährt nur Cypress gegen einen bereits laufenden Server.) + `cypress/e2e/*.cy.ts` (aktuell **20 Tests** in 6 Specs). (`npm run cy:run` fährt nur Cypress + gegen einen bereits laufenden Server.) - **Selektor-Konvention:** UI-Elemente werden über `data-cy="…"`-Attribute angesprochen (entkoppelt von CSS-Klassen & i18n-Text). Custom-Commands in `cypress/support/commands.ts`: `cy.getByCy(sel)` und `cy.visitWithMode(path, 'empty'|'few'|'many')` (setzt diff --git a/frontend/angular.json b/frontend/angular.json index fc10b85..a6fd791 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -87,9 +87,17 @@ { "replace": "src/app/mocks/mock.service.ts", "with": "src/app/mocks/mock.service.production.ts" + }, + { + "replace": "src/app/ui/mock-data-switcher/index.ts", + "with": "src/app/ui/mock-data-switcher/index.production.ts" } ], - "outputHashing": "all" + "outputHashing": "all", + "subresourceIntegrity": true, + "security": { + "autoCsp": true + } }, "mocks": { "fileReplacements": [ diff --git a/frontend/cypress/e2e/policy-legal-text.cy.ts b/frontend/cypress/e2e/policy-legal-text.cy.ts new file mode 100644 index 0000000..81c83f4 --- /dev/null +++ b/frontend/cypress/e2e/policy-legal-text.cy.ts @@ -0,0 +1,39 @@ +/// + +// Der auf der Detailseite angezeigte Rechtstext wird jedes Mal neu aus den Constraints +// abgeleitet; rechtlich maßgeblich ist aber der beim Speichern übermittelte und persistierte +// Text. Weichen beide voneinander ab, warnt die Seite (siehe `hasDivergingLegalText`). +// +// Hier wird der Gutfall abgesichert: eine über die UI gespeicherte Policy darf NIE eine +// Warnung zeigen. Das ist der Regressionsschutz für die Ableitungslogik — ändert sie sich, +// ohne dass `legalText` neu erzeugt wird, schlägt dieser Test an. +// +// Der Schlechtfall (manipulierter legalText bei unveränderten Constraints) lässt sich hier +// nicht ehrlich nachstellen: MirageJS ersetzt XMLHttpRequest im Browser vollständig, und +// cy.intercept sieht die Requests deshalb nie. Er ist stattdessen als Unit-Test auf +// `hasDivergingLegalText` in legal-description.helper.spec.ts abgedeckt. + +// zugriff-konsortium-mitglieder: ACCESS mit einer MEMBERSHIP-Bedingung. +const POLICY_ID = '00000000-0000-0000-0000-000000000002'; + +describe('Policy – Rechtstext', () => { + it('zeigt nach dem Speichern über die UI keine Abweichungs-Warnung', () => { + cy.visitWithMode(`/policies/${POLICY_ID}/edit`, 'few'); + cy.getByCy('submit-policy').click(); + + // Detailseite ist erreicht und der Rechtstext gerendert … + cy.getByCy('policy-title').should('exist'); + cy.getByCy('legal-text').should('exist'); + // … ohne dass gespeicherter und abgeleiteter Text auseinanderlaufen. + cy.getByCy('legal-text-diverged').should('not.exist'); + }); + + it('zeigt für eine Policy ohne gespeicherten Rechtstext keine Warnung', () => { + // Die Seed-Datensätze tragen keinen legalText — ohne gespeicherten Text gibt es + // nichts zu vergleichen, und es darf kein Fehlalarm entstehen. + cy.visitWithMode(`/policies/${POLICY_ID}`, 'few'); + + cy.getByCy('legal-text').should('exist'); + cy.getByCy('legal-text-diverged').should('not.exist'); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fcfb001..75ba640 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,15 +8,15 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.0", + "@angular/animations": "^21.2.19", "@angular/cdk": "^21.2.2", - "@angular/common": "^21.2.0", - "@angular/compiler": "^21.2.0", - "@angular/core": "^21.2.0", - "@angular/forms": "^21.2.0", + "@angular/common": "^21.2.19", + "@angular/compiler": "^21.2.19", + "@angular/core": "^21.2.19", + "@angular/forms": "^21.2.19", "@angular/material": "^21.2.2", - "@angular/platform-browser": "^21.2.0", - "@angular/router": "^21.2.0", + "@angular/platform-browser": "^21.2.19", + "@angular/router": "^21.2.19", "@jsverse/transloco": "^8.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -24,7 +24,7 @@ "devDependencies": { "@angular/build": "^21.2.2", "@angular/cli": "^21.2.2", - "@angular/compiler-cli": "^21.2.0", + "@angular/compiler-cli": "^21.2.19", "@eslint/js": "^10.0.1", "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "21.3.0", @@ -438,9 +438,9 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.18.tgz", - "integrity": "sha512-zN++Qb4Oz4x/5LYHuW9FItm7aHsV4HBz518GT25QusMvGymh+4io2TWhPYB0C9jx+g5q8GeQLd3c8r42yLrRxw==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.19.tgz", + "integrity": "sha512-qC0mviselvpKq2ID9bT+USnCsHKfwZAifkUr5A4h/WCzXvUvpVNWp/RPhGcKeOR+seg3EO5GtIZyblwiRlhzrA==", "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", "license": "MIT", "dependencies": { @@ -450,7 +450,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.18" + "@angular/core": "21.2.19" } }, "node_modules/@angular/build": { @@ -605,9 +605,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.18.tgz", - "integrity": "sha512-gZugZ8gX/KkACIZ3/ekoImLu5z8a0Iu9O5b84kh8e+++VUjmkmd19IygBsq/8/fF3vKf0UcIKcx3P6A/gb2DJw==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.19.tgz", + "integrity": "sha512-Rvo/VXI0kUmfQT7+OeAjv526OJlf/WrLnJq1Tz84Jkyv9bs9SOMTCT6m4+boo8gxVNdrcxvGU3Q0o2jZzKceSg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -616,14 +616,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.18", + "@angular/core": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.18.tgz", - "integrity": "sha512-ccnDuKLuzIa0ayijR+alarsHNWIksuGV/lxGTZ0t6/0+B6J/RXupz6M2IO6ZHHEcg8r7pcrLCUBTyp3FoiRJrg==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.19.tgz", + "integrity": "sha512-vuF5i1t14ftJiHXVVLgDYLWkT99QuajphcUHy1VZaMX3FiqSGXRV62g+R2RMxL0OJ5C1ai8xTHKPx9n1lQFyFg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -633,9 +633,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.18.tgz", - "integrity": "sha512-L5zbIp7YfTTB4I4xT33FgEBanzhppNejzZX0HJOEqKDyDL2jXq+flt83lE0XVuRJ6/zrG+UKj0B+y/CK6Ro7Wg==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.19.tgz", + "integrity": "sha512-QxWqUvhTWgyYPPkfpupOUIEa3Y5cbzMilaFgRNpkvFy6teARw5Izsd7RxUbj3Tp3xJOi1MtumNmkxLxmv3iv7A==", "dev": true, "license": "MIT", "dependencies": { @@ -656,7 +656,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.18", + "@angular/compiler": "21.2.19", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -666,9 +666,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.18.tgz", - "integrity": "sha512-AG4bb6GU0+qp+vVBjc/IhSPjgcRX8QsCb8jzN8dDyhnjsyuuG/LlIiU0l6n65BI9SGKE9m6TfsJ1ObkkAiE5KQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.19.tgz", + "integrity": "sha512-PVoXD1kBexOJLkFzKx2zBY/0oZJXGru0eGn2hu0q5n3vkZlYsR1yRolfGenK1gZE48Qibiw/ttg/X/pAIPEZGg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -677,7 +677,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.18", + "@angular/compiler": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -691,9 +691,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.18.tgz", - "integrity": "sha512-TrRuiNjIzrrNtQgpJVH5gultQrvBlawa+tTzIpxBfIqLpkHrTPZLtYu118EUk6jhWzgRhKYUorInjJe+sSxC+w==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.19.tgz", + "integrity": "sha512-tEw8cz2UU6VSB+ReJN86k87nRdLRXGi+8SZhoRl2dFu2iReIO3S6kJAVTH7Y9kdrokqTPhWG+KNEzWgqhdjXOg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -703,9 +703,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.18", - "@angular/core": "21.2.18", - "@angular/platform-browser": "21.2.18", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19", + "@angular/platform-browser": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -727,9 +727,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.18.tgz", - "integrity": "sha512-zltF+3HrlgtZbYg8U98LhG0dyGm1aHT3Px8//9wjqi/TUY8UlHsYKByL197QtVWDBjf/dekzXdz5c+iyVtanLQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.19.tgz", + "integrity": "sha512-YMguYVhdkV8tr9MvbN+VpMjbdmsYq6g12M9WPvAYM51BkL2iREbWeoXDGmfCw9G0it6+0pMdgrSPFFUFuOqpAQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -738,9 +738,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.18", - "@angular/common": "21.2.18", - "@angular/core": "21.2.18" + "@angular/animations": "21.2.19", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19" }, "peerDependenciesMeta": { "@angular/animations": { @@ -749,9 +749,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.18.tgz", - "integrity": "sha512-Xix19uG1YthC8IslhWKSfPdhscVWREBGVJZW2OnRmLef8F7DvhF49S6vOywaBo+Uahe0egkmgWDNpEwxAzsgLw==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.19.tgz", + "integrity": "sha512-cKO/aq1xEMvgS29jFUc/Y3KsgEzHnwOw6sEL+UQZkZTmGD5YrYv8Yvj05GDMEUYbvDxBd5DixVVEbH38lyQKqA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -760,9 +760,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.18", - "@angular/core": "21.2.18", - "@angular/platform-browser": "21.2.18", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19", + "@angular/platform-browser": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, diff --git a/frontend/package.json b/frontend/package.json index ed3b035..97bbbb1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,15 +22,15 @@ "private": true, "packageManager": "npm@11.0.0", "dependencies": { - "@angular/animations": "^21.2.0", + "@angular/animations": "^21.2.19", "@angular/cdk": "^21.2.2", - "@angular/common": "^21.2.0", - "@angular/compiler": "^21.2.0", - "@angular/core": "^21.2.0", - "@angular/forms": "^21.2.0", + "@angular/common": "^21.2.19", + "@angular/compiler": "^21.2.19", + "@angular/core": "^21.2.19", + "@angular/forms": "^21.2.19", "@angular/material": "^21.2.2", - "@angular/platform-browser": "^21.2.0", - "@angular/router": "^21.2.0", + "@angular/platform-browser": "^21.2.19", + "@angular/router": "^21.2.19", "@jsverse/transloco": "^8.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -38,7 +38,7 @@ "devDependencies": { "@angular/build": "^21.2.2", "@angular/cli": "^21.2.2", - "@angular/compiler-cli": "^21.2.0", + "@angular/compiler-cli": "^21.2.19", "@eslint/js": "^10.0.1", "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "21.3.0", diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index 80c664d..2774d49 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -1,5 +1,11 @@ +:host { + display: flex; + flex-direction: column; + min-height: 100vh; +} + .app-content { - min-height: calc(100vh - 64px); + flex: 1; // Programmatic focus target for skip link / route changes — the landmark // itself is not an interactive control, so no visible ring is needed. diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 14d08c1..4a375e2 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -6,7 +6,7 @@ import { TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { filter } from 'rxjs'; import { environment } from '@env'; import { HeaderComponent } from '@ui/header/header.component'; -import { MockDataSwitcherComponent } from '@ui/mock-data-switcher/mock-data-switcher.component'; +import { MockDataSwitcherComponent } from '@ui/mock-data-switcher'; @Component({ selector: 'app-root', diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 297812d..2691f12 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -9,13 +9,14 @@ import { import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import { provideRouter, TitleStrategy } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { MAT_DATE_LOCALE, MAT_DATE_FORMATS, DateAdapter } from '@angular/material/core'; import { provideTransloco } from '@jsverse/transloco'; import { TranslocoHttpLoader } from './services/transloco-loader.service'; import { ConXTitleStrategy } from '@services/a11y/title-strategy.service'; import { ConXDateAdapter } from '@shared/adapters/con-x-date.adapter'; +import { httpErrorInterceptor } from '@services/http/http-error.interceptor'; import { MockService } from '@mocks/mock.service'; import { environment } from '@env'; @@ -49,7 +50,7 @@ export const appConfig: ApplicationConfig = { environment.useMocks ? inject(MockService).mirageJsServer() : Promise.resolve(), ), provideRouter(routes), - provideHttpClient(), + provideHttpClient(withInterceptors([httpErrorInterceptor])), provideAnimationsAsync(), { provide: TitleStrategy, useClass: ConXTitleStrategy }, { provide: LOCALE_ID, useValue: 'de-DE' }, diff --git a/frontend/src/app/mocks/data/policies/mocked-policies.ts b/frontend/src/app/mocks/data/policies/mocked-policies.ts index 36fa824..ef7fb89 100644 --- a/frontend/src/app/mocks/data/policies/mocked-policies.ts +++ b/frontend/src/app/mocks/data/policies/mocked-policies.ts @@ -5,9 +5,24 @@ import { BehaviorSubject } from 'rxjs'; export type PolicyMockMode = 'empty' | 'few' | 'many'; -const policyMockMode$ = new BehaviorSubject( - (localStorage.getItem('mock-policy-mode') as PolicyMockMode) || 'few', -); +const MOCK_MODE_KEY = 'mock-policy-mode'; +const DEFAULT_MOCK_MODE: PolicyMockMode = 'few'; + +/** + * `localStorage` ist nicht immer verfügbar: bei blockiertem Site-Storage (Enterprise-Policy, + * Safari-Lockdown) oder in einem eingebetteten `