kundenportal-demo · Konzept

Architektur: Zonen und Frontend#

Stand: 2026-09-30 · Beschreibt den Ist-Stand des Codes (Phase 2 abgeschlossen; Seiten für Mandanten und Demo-Pass aus Phase 4 in Abschnitt 8; gemeinsame Bausteine, Content Security Policy und cachebare Startseite in Abschnitt 9–11), nicht die Zielarchitektur. Kennzeichnung: [B] belegt (offizielle Quelle oder Messung), [A] Annahme, [E] Einschätzung.

Fachbegriffe sind in jedem Abschnitt beim ersten Vorkommen mit dem Glossar verlinkt (Erklärung und Entsprechung außerhalb von AWS).

Diese Seite ergänzt die Architektur um die Oberfläche: wie das Portal in mehrere Next.js-Apps zerfällt, wie der Browser schreibt, wie das Laufzeit-Widget „Glocke" eingebunden ist und was die Component Library liefert. Warum Multi-Zones statt Module Federation, steht in Next.js-Betrieb.

1. Zonen im Überblick#

Das Portal besteht aus der Shell und weiteren Zonen: je eine eigene Next.js-App mit eigenem basePath, eigener Lambda und eigenem Pfadbereich unter derselben Domain. Welche Zonen es gibt, steht an genau einer Stelle, der Registry infra/cdk/lib/zones.ts; App-Stack und Edge-Stack lesen sie beide.

Zone Pfad App Stand 30.09.2026
Shell / (alles, was keine Zone ist) apps/shell Startseite, Anmeldung, Konto mit Profil-Bearbeitung, Demo-Postfach mit „als gelesen markieren", Glocke; seit Phase 4 Demo-Pass einlösen und Pass-Status (Abschnitt 8). Startseite und Einlöseseite sind vorgerendert und für alle gleich (Abschnitt 11)
contracts /vertraege apps/contracts Vertragsübersicht, Detailseite mit Abschlag und Tarifoption (J6), Dokumente mit Upload per Presigned URL
consumption /verbrauch apps/consumption Zählerstand-Verlauf und -Erfassung mit Plausibilitätsprüfung (J4), Zählerfoto, Datenvolumen Mobilfunk
cockpit /cockpit apps/cockpit Migrations-Cockpit (Phase 3, Altsysteme & Migration); seit Phase 4 Pass-Verwaltung unter /cockpit/paesse, dort auch die Einstellungen (Einlösen offen/gesperrt, Höchstzahl)
Zonen: eine Domain, mehrere Next.js-Apps
NutzerBrowserSession-Cookie, <kp-bell>RandCloudFronteine Lambda-OAC für alleZonenZonenShell-Lambda/ — Anmeldung, Konto,PostfachZone Verträge/vertraege/*Zone Verbrauch/verbrauch/*S3 (Assets)/_next/static/*, /widgets/*DiensteHTTP API/api/* mit Access Token ausder Sitzung
Daten als Tabelle
SchichtKomponente
NutzerBrowser (Session-Cookie, <kp-bell>)
RandCloudFront (eine Lambda-OAC für alle Zonen)
ZonenShell-Lambda (/ — Anmeldung, Konto, Postfach)
ZonenZone Verträge (/vertraege/*)
ZonenZone Verbrauch (/verbrauch/*)
ZonenS3 (Assets) (/_next/static/*, /widgets/*)
DiensteHTTP API (/api/* mit Access Token aus der Sitzung)

Ein Wechsel zwischen Zonen ist ein voller Seitenwechsel (gewöhnlicher Link, kein clientseitiges Routing): Jede Zone hat ihr eigenes JavaScript-Bundle, und nur innerhalb einer Zone navigiert Next.js ohne Neuladen [E].

2. Aufbau einer Zone#

Eine Zone hinzuzufügen heißt: eine App unter apps/ mit basePath und output: "standalone" und ein Eintrag in zones.ts. Den Rest leiten die Stacks aus der Registry ab:

Baustein Umsetzung
Paket scripts/package-next-lambda.mjs <app>: Standalone-Build als Zip (mit den pnpm-Symlinks); die statischen Dateien (.next/static) lädt der Edge-Stack je Zone in den Asset-Bucket unter <basePath>/_next/static/
Lambda Construct NextLambda (infra/cdk/lib/next-lambda.ts), dasselbe wie für die Shell: Lambda Web Adapter, Response Streaming, 1024 MB, 15 s, Function URL mit AWS_IAM; Bereitschaftsprüfung unter <basePath>/healthz
Übergabe an den Edge SSM /kundenportal/app/zones/<id>/function-arn und /kundenportal/app/zones/<id>/origin-domain
CloudFront je Zone drei Cache-Behaviors: <basePath> und <basePath>/* (alle HTTP-Methoden, kein Cache) sowie <basePath>/_next/static/* aus S3 (gecacht; jede neue Fassung einer Datei bekommt einen neuen Namen)
Proxy src/proxy.ts mit createCspProxy aus @kundenportal/web-auth/csp: setzt die Content Security Policy jeder Antwort (Abschnitt 10)
Signatur eine gemeinsame OAC vom Typ lambda für Shell und alle Zonen (originAccessControlId am Ursprung)
Aufrufrechte je Zone lambda:InvokeFunctionUrl und lambda:InvokeFunction nur für diese Distribution, wie bei der Shell
Umgebung API_URL, OIDC_CLIENT_ID, COGNITO_USER_POOL_ID, APP_URL; die Zone darf wie die Shell DescribeUserPoolClient aufrufen (Schlüssel der Sitzung, siehe Abschnitt 3)

Statische Dateien kommen für Shell und Zonen aus dem S3-Bucket des Edge-Stacks. Anfangs lieferten die Zonen sie selbst aus; der erste Live-Test brach dann mit ReservedFunctionConcurrentInvocationLimitExceeded ab: ein erster Seitenaufruf lädt viele Chunks gleichzeitig, mehr als eine Reserved Concurrency von 2 zulässt [B: Live-Test 30.09.2026]. Seither laufen nur Seiten und Route Handler über die Lambda; die Next.js-Funktionen (Shell, Zonen) haben eine Reserved Concurrency von 5 (CDK-Kontext webReservedConcurrency), die Services weiterhin 2.

3. Anmeldung in den Zonen#

Die Anmeldung bleibt in der Shell; Zonen haben keinen eigenen OIDC-Ablauf. Das gemeinsame Paket @kundenportal/web-auth gibt jeder Zone dieselben Werkzeuge:

Funktion Zweck
readSession() liest und entschlüsselt das Cookie kp_session der Shell (JWE, Schlüssel per HKDF aus dem Client-Secret, das die Zone wie die Shell aus dem User Pool liest); abgelaufene Sitzungen gelten als fehlend
apiFor(session) typisierter API-Client mit dem Access Token aus der Sitzung, serverseitig
loginUrl(returnTo) absolute Adresse https://<Domain>/auth/login?returnTo=… der Shell. Absolut, weil Next.js relative Weiterleitungsziele innerhalb einer Zone mit dem basePath präfixt — aus /auth/login würde sonst /vertraege/auth/login
isSameOrigin(headers, appUrl) CSRF-Prüfung für schreibende Anfragen (Abschnitt 4)
currentSession(), requireSession(path) aus @kundenportal/web-auth/pages: Sitzung einmal je Anfrage lesen bzw. ohne Sitzung zur Anmeldung weiterleiten. Eigener Einstiegspunkt, weil er next/navigation nutzt, das Route Handler nicht laden dürfen

Ohne Sitzung leitet eine Zone zur Shell-Anmeldung weiter und kommt danach über returnTo zurück. Der Browser hält weiterhin nur Cookies, nie Tokens (BFF).

4. Schreibweg aus dem Browser#

Problem: CloudFront signiert Anfragen an die Function URLs per OAC mit SigV4. Für Anfragen mit Body (POST, PUT, PATCH) signiert CloudFront den Inhalt nicht selbst; der Absender muss den SHA-256 des Bodys im Header x-amz-content-sha256 mitschicken [B: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html]. Ein gewöhnliches HTML-Formular kann das nicht.

Lösung: Alle schreibenden Aufrufe aus dem Browser laufen über sendJson(method, url, body) aus @kundenportal/web-auth/browser. Die Funktion serialisiert den Body, berechnet den Payload-Hash mit WebCrypto und sendet beides per fetch (nur gleiche Herkunft, mit Cookie).

CSRF-Schutz in zwei Schichten:

  1. Das Sitzungs-Cookie ist SameSite=Lax; fremde Seiten können damit keine POST-Anfragen mit Sitzung auslösen.
  2. Jeder Route Handler, der schreibt, prüft den Origin-Header: nur die Portal-Domain, sonst 403; danach die Sitzung, sonst 401 (guardWrite in der Shell). Fehlt Origin, gilt die Anfrage als fremd — fetch sendet ihn bei allen Nicht-GET-Anfragen.

Schreibende Route Handler der Shell (Stand 30.09.2026):

Route (Shell) Methode ruft die API Zweck
/konto/profil PATCH PATCH /api/me Anzeigename und Sprache ändern
/postfach/<id>/gelesen POST PATCH /api/notifications/<id> Nachricht als gelesen markieren
/postfach/anzahl GET GET /api/notifications Zahl der ungelesenen Nachrichten für die Glocke (401 ohne Sitzung)

Das Verhalten der Shell in CloudFront erlaubt dafür jetzt alle HTTP-Methoden (Phase 1: nur GET, HEAD, OPTIONS).

5. Laufzeit-Widget „Glocke"#

<kp-bell> (packages/widget-notifications) zeigt die Zahl der ungelesenen Nachrichten und verlinkt aufs Postfach. Es ist die zweite Integrationsart neben Multi-Zones: eine Laufzeit-Integration im Browser, die jede Zone unabhängig von ihrem eigenen Stack einbinden kann.

6. Component Library und Storybook#

packages/ui ist die gemeinsame Component Library aller Zonen; die Shell baut bereits vollständig darauf auf.

Teil Inhalt
Komponenten AppShell/TopBar (mit Platz für die Glocke), Page, Card, Facts, DataTable, Button/ButtonLink, TextField, NumberField, Select, Notice, Badge, EmptyState, Footer, Meter: reines React ohne Client-Zustand, daher als Server Components nutzbar. Dazu UploadForm (Client-Komponente, Abschnitt 9)
Navigation portalNavigation(texte, {signedIn, current, roles}) baut die Hauptnavigation für Shell und Zonen; der Eintrag des aktuellen Bereichs trägt aria-current="page" und ist fett mit dicker Unterstreichung in Akzentfarbe (auch im Kontrastmodus sichtbar). Die Shell leitet den Bereich aus dem Pfad im Browser ab, jede Zone markiert ihren eigenen Eintrag. roles (aus den Cognito-Gruppen des Tokens, rolesOf in web-auth; auf vorgerenderten Seiten aus dem Hinweis-Cookie kp_ui): Inhaber und Pass-Inhaber sehen „Cockpit“, Pass-Inhaber zusätzlich „Demo-Pass“ — in Shell und allen Zonen gleich
Version in der Kopfzeile TopBar zeigt neben der Marke die ausgerollte Version, z. B. v0.4.1 · 1a2b3c4: next.config.ts setzt NEXT_PUBLIC_APP_VERSION beim Build aus der Version in der Wurzel-package.json und dem Commit (GITHUB_SHA, lokal git; scripts/app-version.mjs)
Hilfsfunktionen Formatierung (formatEuro, formatDate, formatDateTime, formatFileSize, formatQuantity, formatDataVolume, formatNumber, percent), fill für Platzhalter in Texten, createZoneLink(basePath, Link)
Design-Tokens CSS-Variablen --kp-* für hell und dunkel; folgt prefers-color-scheme, data-theme erzwingt eine Variante
Übersetzungen gemeinsame Texte DE/EN (Navigation, An-/Abmelden, Sprachwechsel, Fußzeile) und Sprachauswahl aus Cookie und Accept-Language; zonenspezifische Texte bleiben in den Zonen
Tests 37 (Vitest, Testing Library)
Storybook Version 10.6, statisch gebaut; Umschalter für Sprache und Hell/Dunkel, 360-px-Ansicht voreingestellt

Der Workflow Pages veröffentlicht Storybook zusammen mit diesen Berichten auf GitHub Pages: https://janpfeil.github.io/kundenportal-demo/storybook/ — kostenlos und ohne S3 oder CloudFront (Kostenfreier Betrieb §3).

7. Messwerte#

Messgröße Wert Gemessen am Anmerkung
Deploy nur der Zonen (lokal) 212 s 30.09.2026 Build, App- und Edge-Stack
Größe bell.js 2,3 kB 30.09.2026 Vite-Build
POST ohne x-amz-content-sha256 403 30.09.2026 CloudFront, live

Weitere Messwerte, auch der Deploy mit allen Services, stehen in Architektur §9.

8. Seiten für Mandanten und Demo-Pass (Phase 4)#

Die Architektur dahinter steht in Architektur: Mandanten und Demo-Pass.

Route Zone Art Wer Zweck
/pass/einloesen Shell Seite öffentlich Einladungslink einlösen: Token nur im URL-Fragment (#…), ALTCHA-Widget, danach Hinweis auf das Einmal-Passwort
/pass/einloesen/challenge Shell Route Handler, GET öffentlich holt das Rätsel von GET /api/tenancy/challenge
/pass/einloesen/api Shell Route Handler, POST öffentlich prüft den Origin-Header (sonst 403), gibt die IP des Besuchers als x-kp-client-ip weiter, ruft POST /api/tenancy/redeem
/pass Shell Seite Gruppe pass Status des eigenen Passes: Einrichtung, gültig bis, Kontingente als Meter (neu in packages/ui), Demo-Personen mit Anmeldenamen und Demo-Passwort
/cockpit/paesse Cockpit Seite Gruppe owner Einladungen erzeugen (Link wird genau einmal angezeigt), Pässe mit Status und Kontingent, Widerruf
/cockpit/api/invitations Cockpit Route Handler, POST Gruppe owner POST /api/tenancy/invitations
/cockpit/api/passes/<id>/revoke Cockpit Route Handler, POST Gruppe owner POST /api/tenancy/passes/{id}/revoke
/cockpit/api/settings Cockpit Route Handler, PUT Gruppe owner PUT /api/tenancy/settings: Einlösen öffnen/sperren, Höchstzahl 1–4
/api/tenancy/offer API GET, vom Browser öffentlich aktuelles Angebot für /pass/einloesen (Laufzeit, Kontingente, Upload-Größe, Einlösen offen)
/cockpit Cockpit Seite Gruppe pass Migrationsansichten des eigenen Mandanten (Mandant aus dem Token)

Die Navigation der Shell hat dafür den Eintrag „Demo-Pass". Die beiden öffentlichen Route Handler brauchen keine Sitzung; sie laufen wie alle schreibenden Aufrufe über sendJson mit Payload-Hash (Abschnitt 4). Die Zahlen auf /pass/einloesen (Laufzeit, Kontingente, höchste Upload-Größe) holt der Browser von GET /api/tenancy/offer: gleiche Herkunft, ohne Token, ohne Umweg über die Shell-Lambda. Solange die Antwort fehlt, steht „Wird geladen …“ da, bei einem Fehler „nicht abrufbar“; das Formular bleibt dann nutzbar. Meldet die API redemptionOpen: false (Kill-Switch zu oder alle Plätze belegt), erscheint statt des Formulars der Hinweis „Einlösen ist gerade pausiert“.

Auf /cockpit/paesse sieht der Inhaber die Einstellungen: Einlösen offen oder gesperrt (mit Zeitpunkt und Grund, z. B. vom Budget-Alarm), Schaltfläche zum Sperren bzw. Wiederöffnen, die Zahl der aktiven Pass-Mandanten und die Höchstzahl 1–4 mit Begründung: Jeder Mandant hat eine Tabelle mit 5/5 Kapazitätseinheiten, frei sind 25/25 je Konto, die Basis belegt 5/5 (Mandanten §6). Ein erschöpftes Upload-Kontingent (429 vom Documents-Service) meldet das Upload-Formular mit eigenem Text.

9. Gemeinsame Bausteine der Zonen#

Was jede Zone gleich braucht, liegt in den Paketen; in der Zone bleiben nur basePath, Texte und Seiten.

Baustein Paket Zweck
forwardWrite(request, parse, call) @kundenportal/web-auth Schreibweg (Abschnitt 4): Origin prüfen (403), Sitzung (401), Body validieren (400), API mit dem Token der Sitzung aufrufen; Status und Problem Details der API gehen unverändert zurück, 204 ohne Body. writePath(deps) baut dieselbe Funktion mit austauschbaren Abhängigkeiten für Tests
problem(status, title, detail?) @kundenportal/web-auth Fehlerantwort der Zone als RFC 9457 application/problem+json
currentSession, requireSession @kundenportal/web-auth/pages Abschnitt 3
Upload-Regeln @kundenportal/web-auth/upload erlaubte Typen und Größe, Dateiname bereinigen, Ankündigung im Browser bauen und im Route Handler prüfen; ohne Server-APIs, also auch im Browser nutzbar
UploadForm @kundenportal/ui Upload in zwei Schritten (Ankündigung an die Zone, dann PUT direkt an die Presigned URL); Texte je Zone, nach dem Upload ruft die Zone router.refresh()
Formatierung, fill, Navigation, createZoneLink @kundenportal/ui Abschnitt 6

10. Content Security Policy#

Shell und Zonen senden eine Content Security Policy (CSP). Gesetzt wird sie im proxy jeder App (src/proxy.ts, ab Next.js 16 der Nachfolger von middleware.ts) über createCspProxy aus @kundenportal/web-auth/csp.

Direktive Wert Grund
default-src 'self' alles Übrige nur von der Portal-Domain
script-src 'self' + Nonce bzw. Hashes Skriptdateien nur von der eigenen Domain (/_next/static, /widgets/bell.js); Next.js schreibt zusätzlich Inline-Skripte in jede Seite, die nur mit Nonce oder Hash laufen
style-src 'self' 'unsafe-inline' React setzt Style-Attribute (z. B. Meter), ALTCHA fügt ein <style> ein; Styles führen keinen Code aus [E]
img-src 'self' data: blob: eigene Bilder, eingebettete Grafiken
connect-src 'self', in Verträge und Verbrauch zusätzlich <https://*.s3.eu-central-1.amazonaws.com> fetch nur zur eigenen Domain (Route Handler, /api/*); der Upload geht per PUT an die Presigned URL des Upload-Buckets
worker-src 'self', in der Shell zusätzlich blob: das ALTCHA-Widget rechnet in einem Web Worker aus einem Blob
form-action 'self', in der Shell zusätzlich die Cognito-Domain (Herkunft von OIDC_LOGOUT_URL) Anmelden und Abmelden leiten zur Cognito-Anmeldeseite weiter
frame-ancestors 'none' keine Einbettung in fremde Seiten (Clickjacking)
base-uri, object-src, manifest-src 'self', 'none', 'self' kein fremdes <base>, keine Plugins
upgrade-insecure-requests nur hinter HTTPS (APP_URL) lokal über <http://localhost> nicht

Zwei Arten von Seiten:

'strict-dynamic' ist nicht gesetzt: Alle Skripte kommen ohnehin von der eigenen Domain, und ohne 'strict-dynamic' funktionieren Nonce und Hash gleich. In next dev kommt 'unsafe-eval' hinzu (Fehleranzeige von React).

Lokal belegt [B: Produktionsbuild, headless Chromium, 30.09.2026]: keine Verletzung und vollständige Hydration auf Startseite (deutsch, englisch per Cookie und per Browsersprache), Einlöseseite (Angebot geladen, ALTCHA im Worker gelöst; zweiter Lauf mit pausiertem Einlösen), Konto, Postfach, Verträge mit Upload bis zum PUT an eine S3-Adresse, Verbrauch, Cockpit-Einstellungen (sperren, öffnen, Höchstzahl ändern) und 404-Seite. Gegenprobe: ein eingeschleustes Inline-Skript und ein fetch an eine fremde Domain werden blockiert.

11. Cachebare Startseite#

Bis 30.09.2026 las das gemeinsame Layout der Shell Cookies (Sitzung für die Navigation, Sprache); damit war jede Shell-Seite dynamisch und / kam mit private, no-store. Jetzt hat die Shell zwei Root-Layouts (Route Groups):

Gruppe Seiten Layout
(public) /, /pass/einloesen liest weder Cookies noch Header; Next.js rendert die Seiten beim Build vor, das HTML ist für alle Besucher gleich
(app) /konto, /postfach, /pass je Anfrage: Sitzung und Sprache vom Server, Rahmen ab dem ersten Byte richtig

Die 404-Seite liefert app/global-not-found.tsx (zweisprachig), weil es kein gemeinsames Layout mehr gibt. Ein Wechsel zwischen den Gruppen ist ein voller Seitenwechsel, wie zwischen Zonen.

Wie die vorgerenderten Seiten den Besucher erkennen:

Antwort-Header: Cache-Control: public, max-age=0, s-maxage=300 (setzt der Proxy statt Next.js' s-maxage=31536000, damit eine vergessene Invalidierung höchstens fünf Minuten alte Asset-Namen ausliefert), ETag, dazu Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch. Browser fragen jedes Mal nach, CloudFront darf fünf Minuten halten.

Was der Edge-Stack dafür braucht (Stand dieser Seite: noch nicht gebaut): eigene Cache-Behaviors für / und /pass/einloesen zum Shell-Ursprung mit einer Cache Policy, die den Origin-Header Cache-Control achtet (Min-TTL 0, Default-TTL 0, Max-TTL 300 s), keine Cookies und keine Header im Cache-Schlüssel, aber alle Query-Strings (Next.js unterscheidet die RSC-Anfragen beim Seitenwechsel über ?_rsc=…), GET/HEAD; dazu eine Invalidierung von / und /pass/einloesen bei jedem Deploy.

Quellen#