---
phase: 261008-who-eigene-module-beim-start-vorladen
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql
- packages/shared/src/index.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/api/src/custom-modules/custom-modules.service.spec.ts
- apps/api/src/custom-modules/custom-modules.controller.ts
- apps/api/src/custom-modules/custom-modules.controller.spec.ts
- apps/api/src/custom-modules/dto/custom-module.dto.ts
- apps/api/src/custom-modules/dto/custom-module.dto.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/lib/custom-modules-api.test.ts
- apps/web/src/lib/custom-module-preload.ts
- apps/web/src/lib/custom-module-preload.test.ts
- apps/web/src/lib/stores/custom-module-cache-store.ts
- apps/web/src/lib/stores/custom-module-cache-store.test.ts
- apps/web/src/components/modules/custom-module-preloader.tsx
- apps/web/src/components/modules/custom-module-preloader.test.tsx
- apps/web/src/components/modules/custom-module-frame-host.tsx
- apps/web/src/components/modules/custom-module-frame-host.test.tsx
- apps/web/src/components/modules/custom-module-view.tsx
- apps/web/src/components/modules/custom-module-view.test.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/components/custom-modules/custom-module-preload-settings.tsx
- apps/web/src/components/custom-modules/custom-module-preload-settings.test.tsx
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- .planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh
- docs/anleitung-anwender.md
- CHANGELOG.md
autonomous: true
requirements: [QUICK-261008-WHO]
estimate:
tokens: 160000
raw_tokens: 160000
tasks: 3
confidence: low
must_haves:
truths:
- "Jeder Benutzer schaltet je eigenem Modul (persönlich UND gemeinsam, soweit sichtbar) „Beim Start vorladen“ ein und aus — in der App-Leiste der Modulansicht und in Einstellungen → Eigene Module; die Wahl liegt serverseitig pro Benutzer und gilt damit in Browser und Desktop-App gleich (D-01)"
- "Die Wahl eines Benutzers ist für keinen anderen Benutzer sichtbar und wirkt nicht auf ihn; eine fremde persönliche Modul-Kennung ergibt beim Schalten 404 ohne Schreibzugriff (D-01)"
- "Kurz NACH dem Laden von Tessera (Anmeldung oder Desktop-Start mit bestehender Sitzung) entstehen die Rahmen der vorgeladenen Module unsichtbar und nacheinander (erster frühestens ca. 2 s nach dem Start, danach je ca. 1,5 s Abstand, im Leerlauf des Browsers), mit derselben Sandbox und nur für https-Adressen ohne Zugangsdaten (D-03)"
- "Der erste Klick auf ein vorgeladenes Modul zeigt den bereits geladenen Rahmen ohne Neuladen: derselbe DOM-Knoten, kein erneutes load-Ereignis, kein zweiter Rahmen (D-03)"
- "Bis zu acht eigene Module bleiben offen; vorgeladene werden nie per LRU verworfen; Vorladen ist serverseitig auf acht begrenzt, der neunte Versuch zeigt einen verständlichen Hinweis (D-02)"
- "Abmelden oder Benutzerwechsel verwirft alle Rahmen und bricht ausstehendes Vorladen ab; gelöschte oder nicht mehr sichtbare Module werden nie vorgeladen (D-04)"
- "Anwenderanleitung und CHANGELOG „Unveröffentlicht“ beschreiben Offenhalten, Vorladen und die Grenze acht (D-05)"
artifacts:
- path: "apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql"
provides: "Spalte User.customModulePreloadIds (TEXT[], NOT NULL, Standard leer)"
- path: "apps/api/src/custom-modules/custom-modules.service.ts"
provides: "setPreload (prüft Sichtbarkeit, Grenze 8, bereinigt), list/getOne liefern preload je Aufrufer"
- path: "apps/api/src/custom-modules/custom-modules.controller.ts"
provides: "PUT /custom-modules/:id/preload"
- path: "apps/web/src/lib/custom-module-preload.ts"
provides: "Vorlade-Planer: Startverzögerung, Leerlauf, Staffelung, Abbruch"
- path: "apps/web/src/components/modules/custom-module-preloader.tsx"
provides: "Startet das Vorladen nach Anmeldung, bricht bei Abmelden/Benutzerwechsel ab"
- path: "apps/web/src/lib/stores/custom-module-cache-store.ts"
provides: "CUSTOM_MODULE_KEEP_ALIVE_MAX = 8, pinned, preload(), setPinned(), LRU ohne Verwerfen gepinnter Module"
- path: "apps/web/src/components/modules/custom-module-frame-host.tsx"
provides: "Ersatzgröße aus dem Inhaltsbereich, bevor ein Platzhalter gemessen wurde"
- path: "apps/web/src/components/custom-modules/custom-module-preload-settings.tsx"
provides: "Karte „Beim Start vorladen“ in Einstellungen → Eigene Module mit Zähler x von 8"
key_links:
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "apps/web/src/components/modules/custom-module-preloader.tsx"
via: " neben "
pattern: "CustomModulePreloader"
- from: "apps/web/src/components/modules/custom-module-preloader.tsx"
to: "apps/web/src/lib/stores/custom-module-cache-store.ts"
via: "listCustomModules → Filter preload + checkCustomModuleUrl → preload(mod) gestaffelt"
pattern: "preload\\("
- from: "apps/web/src/components/modules/custom-module-frame-host.tsx"
to: "apps/web/src/lib/stores/custom-module-cache-store.ts"
via: "openIds (stabile Reihenfolge, key=id) — vorgeladener Rahmen wird bei activate übernommen, nicht neu eingehängt"
pattern: "openIds"
- from: "apps/api/src/custom-modules/custom-modules.service.ts"
to: "User.customModulePreloadIds"
via: "forTenant(this.prisma, tenantId).user — nur die eigene Zeile (where id = caller.id)"
pattern: "customModulePreloadIds"
---
Eigene Module (iframe-basiert) können pro Benutzer „Beim Start vorladen“ bekommen: Tessera lädt sie kurz nach dem Start unsichtbar und gestaffelt im bestehenden Rahmen-Behälter, sodass der erste Klick sofort die fertige Seite zeigt — ohne Neuladen. Die Offenhalten-Grenze steigt auf acht, vorgeladene Module werden nie verworfen.
Purpose: Nutzerwunsch — eigene Module sind beim ersten Aufruf langsam; das Offenhalten (seit 30.09.) hilft erst ab dem zweiten Aufruf.
Output: Benutzer-Einstellung serverseitig (Spalte + PUT-Route), Vorlade-Planer und -Komponente, erweiterter Speicher/Behälter, Schalter in Modulansicht und Einstellungen, Tests, Anleitung, CHANGELOG, Browser-Nachweis.
Gesperrte Nutzerentscheidungen (aus dem Auftrag, hier D-01 bis D-05):
- D-01: Jeder Benutzer entscheidet selbst je Modul (persönlich und gemeinsam, soweit sichtbar); Speicherung serverseitig pro Benutzer; keine Begriffe der Mehrfirmen-Trennung in der Oberfläche.
- D-02: CUSTOM_MODULE_KEEP_ALIVE_MAX = 8; vorgeladene Module nie per LRU verwerfen; Verhalten bei mehr als acht dokumentieren.
- D-03: Vorladen kurz NACH dem Start, gestaffelt, im Leerlauf, unsichtbar, gleiche Sandbox/Attribute, nur checkCustomModuleUrl === 'ok'; vorgeladener Rahmen wird beim ersten Öffnen ohne Neuladen und ohne DOM-Verschieben übernommen; Größe vor dem ersten Platzhalter sinnvoll setzen.
- D-04: Abmelden/Benutzerwechsel verwirft alles; gelöschte/entzogene Module nicht vorladen.
- D-05: Doku in docs/anleitung-anwender.md, docs/anleitung-entwicklung.md nur falls Architektur dort beschrieben, CHANGELOG „Unveröffentlicht“; Modul-Changelog von 261008-w5w prüfen.
Claude's Discretion (hier entschieden und zu dokumentieren):
- Speicherort (D-01): Spalte `User.customModulePreloadIds` (TEXT[]) nach dem Muster `dashboardBackground` (Benutzerzeile, schon unter Zeilenschutz) statt eigener Tabelle — keine neue RLS-Regel nötig. Gelöschte Module bleiben als tote Kennung stehen, werden aber beim Lesen gegen die sichtbaren Module geschnitten und beim nächsten Schreiben entfernt.
- Ort des Schalters (D-01): BEIDE Orte. (a) App-Leiste der Modulansicht neben „In neuem Tab öffnen“ — gilt dort für persönliche UND gemeinsame Module; (b) Karte „Beim Start vorladen“ in Einstellungen → Eigene Module mit ALLEN sichtbaren Modulen und Zähler „x von 8“ — nötig, weil die bestehende Liste dort nur persönliche Einträge zeigt und man beim Erreichen der Grenze sehen muss, welche Module schon vorgeladen werden.
- Grenze (D-02): Gesamtgrenze acht offene Rahmen. Vorgeladene zählen mit, werden aber nie verworfen; die übrigen teilen sich den Rest (mindestens das gerade geöffnete). Vorladen ist serverseitig auf acht begrenzt (HTTP 409, Hinweistext). Extremfall: acht vorgeladen plus ein gerade geöffnetes, nicht vorgeladenes = neun Rahmen; öffnet man ein weiteres nicht vorgeladenes, fällt das vorige nicht vorgeladene heraus.
- Gemeinsame Konstante `CUSTOM_MODULE_PRELOAD_MAX = 8` in `@tessera/shared` (Quelltext-Paket, API und Web importieren es bereits).
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.planning/STATE.md
@CLAUDE.md
@apps/web/src/components/modules/custom-module-frame-host.tsx
@apps/web/src/lib/stores/custom-module-cache-store.ts
@apps/web/src/components/modules/custom-module-view.tsx
@apps/web/src/components/layout/app-shell.tsx
@apps/web/src/lib/custom-modules-api.ts
@apps/api/src/custom-modules/custom-modules.service.ts
@apps/api/src/custom-modules/custom-modules.controller.ts
Projektnotizen (verbindlich):
- Diese Aufgabe läuft NACH Quick 261008-w5w (Modul-Changelog). Nur auf das bauen, was davon committet ist. w5w liefert für eigene Module bewusst eine leere Liste (eigene Module stehen nicht im Modul-Register) — für dieses Vorhaben also NUR der Gesamt-CHANGELOG, kein Modul-Changelog-Eintrag (D-05).
- Nie `.env` lesen. Nie etwas in `gespraech-2026-11/` anfassen oder stagen — immer explizite Dateipfade stagen.
- NestJS: statische GET-Routen vor `@Get(':id')`.
- Lokaler Stack: `docker compose up -d --build api web` (ein schlichtes `up` baut nicht neu); die API führt Migrationen beim Start aus (`apps/api/scripts/migrate-and-start.sh`). Anmeldung lokal admin / admin123 (Standardwerte aus docker-compose.yml). API http://localhost:3001, Web http://localhost:3000.
- App-Texte siezen. Nicht pushen (gebündelt pushen macht der Nutzer).
- Commits enden mit: `Co-Authored-By: Claude Opus 5.5 (1M context) `
Bestehende Bausteine (aus der Analyse, nicht erneut suchen):
- Speicher `useCustomModuleCacheStore`: `modules`, `openIds` (stabile Einhängereihenfolge, key=id — ein umsortiertes oder verschobenes iframe lädt neu), `recent` (LRU), `active` {id, slot}; Aktionen remember/forget/activate/deactivate/clear; reine Funktion `touchLru(recent, id, max)`.
- Behälter `CustomModuleFrameHost`: rendert `openIds.map` im selben Elternknoten, `position: fixed`, versteckt mit `visibility: hidden` + `pointer-events: none` + `aria-hidden`, Größe aus dem gemessenen Platzhalter (`rect`, anfangs null → heute 0×0). Leert bei Benutzerwechsel selbst (`clear()` bei geänderter `useAuthStore` user.id); das Abmelden leert in `header.tsx` (Zeile ~246).
- `CustomModuleView`: lädt `getCustomModule(id)` im Hintergrund, `remember()`, meldet Platzhalter per `activate(id, slot)` an; App-Leisten-Knopf per Portal in `HEADER_ACTIONS_SLOT_ID` (Klasse `btn btn-appbar`).
- `useAuthStore`: `user` (null nach `clearUser()`), `user.id`.
- `useMarketplaceStore`: `sidebarRefreshKey` / `bumpSidebarRefresh()` — die Verwaltungsliste ruft `bumpSidebarRefresh()` nach Anlegen/Ändern/Löschen.
- API: `CustomModulesService.loadVisible(...)` liefert 404 für fremde/fehlende Kennungen; Benutzerzeile schreiben nach Muster `PATCH /users/me/dashboard-background` (`forTenant(this.prisma, tenantId).user.update({ where: { id: caller.id } })`).
- `rls-access-inventory.spec.ts` vergleicht Paare (Datei, Prisma-Modell) gegen `docs/mandantentrennung-zugriffsklassifikation.md` — ein neuer Zugriff `…user` in `custom-modules.service.ts` braucht dort eine Zeile.
- Bestehender Test „haelt hoechstens fuenf Module offen …“ in `custom-module-view.test.tsx` muss auf acht umgestellt werden.
Aufgabe 1 (Durchstich): Einstellung speichern → nach dem Start unsichtbar vorladen → erster Klick übernimmt den Rahmen
apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql, packages/shared/src/index.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/dto/custom-module.dto.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/custom-modules-api.ts, apps/web/src/lib/custom-modules-api.test.ts, apps/web/src/lib/custom-module-preload.ts, apps/web/src/lib/custom-module-preload.test.ts, apps/web/src/lib/stores/custom-module-cache-store.ts, apps/web/src/lib/stores/custom-module-cache-store.test.ts, apps/web/src/components/modules/custom-module-preloader.tsx, apps/web/src/components/modules/custom-module-preloader.test.tsx, apps/web/src/components/modules/custom-module-frame-host.tsx, apps/web/src/components/modules/custom-module-frame-host.test.tsx, apps/web/src/components/layout/app-shell.tsx, .planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh
API (Vitest, Prisma gemockt wie in custom-modules.service.spec.ts):
- list: `preload: true` nur für Module, deren Kennung in der Benutzerzeile DES AUFRUFERS steht; Benutzer B (eigene Zeile leer) bekommt für dasselbe gemeinsame Modul `preload: false` (Benutzertrennung). Die Benutzerzeile wird mit `where: { id: caller.id }` gelesen.
- getOne liefert zusätzlich `preload` (boolesch) für den Aufrufer.
- setPreload(true): schreibt NUR `where: { id: caller.id }`; doppelte Kennung wird nicht doppelt gespeichert; tote Kennungen (nicht mehr sichtbar/gelöscht) werden beim Schreiben entfernt; Antwort `{ id, preload, preloadCount }`.
- setPreload(true) bei schon acht sichtbaren vorgeladenen Modulen → ConflictException, KEIN Schreibzugriff; tote Kennungen zählen dabei nicht mit.
- setPreload(false) entfernt die Kennung.
- fremde persönliche oder unbekannte Kennung → NotFoundException ohne Schreibzugriff.
- Controller: Route PUT `:id/preload` (Metadaten geprüft), reicht `req.tenantId` und Benutzer durch; DTO lehnt fehlendes oder nicht-boolesches `preload` ab.
Web:
- custom-module-preload.test.ts (Fake-Timer): vor Ablauf der Startverzögerung (2000 ms) kein Aufruf; danach Ziele laden und das erste vorladen (über requestIdleCallback, wenn vorhanden — gestubbt; sonst setTimeout-Ersatz); jedes weitere erst nach gapMs (1500 ms); Reihenfolge bleibt; `cancel()` vor dem Start, zwischen zwei Schritten und während eines offenen Leerlauf-Rückrufs verhindert jeden weiteren Aufruf (cancelIdleCallback/clearTimeout).
- custom-module-preloader.test.tsx: mit angemeldetem Benutzer und Liste [A preload https, B ohne preload, C preload mit http-Adresse] steht nach den Timern genau A in `openIds` und `pinned`, B und C nie; wird der Benutzer vor Ablauf null, wird nichts vorgeladen; Benutzerwechsel A→B wendet die Liste des alten Benutzers nicht mehr an.
- custom-module-frame-host.test.tsx: nach `preload(mod)` genau EIN iframe, versteckt (visibility hidden, pointer-events none, aria-hidden), mit XFRAME_SANDBOX, `allow=""`, referrerPolicy no-referrer und Größe ungleich 0 aus dem Inhaltsbereich (getBoundingClientRect des Elements mit `data-app-shell-main` gemockt); danach CustomModuleView für dieselbe id gerendert (getCustomModule gemockt, gleiche Adresse) → weiterhin genau ein iframe, DERSELBE Knoten (`toBe`), `src` unverändert, sichtbar.
- Speicher: `preload(mod)` merkt Metadaten, hängt die id hinten an `openIds` an (falls noch nicht offen), nimmt sie in `pinned` auf, setzt `active` NICHT; zweimal aufgerufen ändert nichts; mehr als CUSTOM_MODULE_PRELOAD_MAX gepinnte werden ignoriert; `forget`/`clear` entfernen auch aus `pinned`.
API-Schicht (D-01, D-02, D-04):
1. In `packages/shared/src/index.ts` `export const CUSTOM_MODULE_PRELOAD_MAX = 8;` mit kurzem Kommentar (eigene Module, die ein Benutzer beim Start vorladen lässt; gleich der Offenhalten-Grenze im Web).
2. `apps/api/prisma/schema.prisma`, Modell User: Spalte `customModulePreloadIds String[] @default([])` mit Kommentar im Stil der Nachbarspalten (quick-261008-who, Kennungen eigener Module, die dieser Benutzer beim Start vorladen lässt; tote Kennungen werden beim Lesen geschnitten). Migration von Hand anlegen: Ordner mit Zeitstempel SPÄTER als der neueste unter `apps/api/prisma/migrations/` (bei Planung 20261008183000; liegt inzwischen ein späterer, Zeitstempel entsprechend erhöhen und `files_modified` im SUMMARY nennen), Name `_user_custom_module_preload`, Inhalt: `ALTER TABLE "User" ADD COLUMN "customModulePreloadIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];`. Danach `pnpm --filter @tessera/api exec prisma generate`. Prüfen, dass keine Antwort mit vollständiger Benutzerzeile die neue Spalte nach außen gibt (auth.service nutzt `select`; in user.service/user.controller Listen mit `findMany` ohne `select` ggf. wie `dashboardBackground` ausschließen).
3. `custom-modules.service.ts`: privater Helfer zum Lesen der eigenen Kennungen über einen eigenen Klienten `const userPrisma = forTenant(this.prisma, tenantId)` (Muster dashboard-background, OHNE Benutzer-Argument, `where: { id: caller.id }`, `select: { customModulePreloadIds: true }`). `list` liefert je Zeile zusätzlich `preload: boolean` (Schnitt mit den sichtbaren Zeilen — so werden gelöschte/entzogene Module nie als vorgeladen gemeldet, D-04). `getOne` liefert ebenfalls `preload`. Neue Methode `setPreload(tenantId, caller, id, preload)`: Sichtbarkeit über `loadVisible` (fremd/fehlend → 404), sichtbare Kennungen des Aufrufers ermitteln (gemeinsame + eigene, nur `select: { id: true }`), aktuelle Liste = gespeicherte ∩ sichtbare (ohne Doppelte), Grenze `CUSTOM_MODULE_PRELOAD_MAX` aus `@tessera/shared` → `ConflictException` mit deutscher Meldung, sonst eigene Zeile mit der bereinigten Liste schreiben und `{ id, preload, preloadCount }` zurückgeben. Kopfkommentar der Klasse um einen Absatz zum Vorladen ergänzen (Speicherort, Bereinigung, Grenze, nur eigene Zeile).
4. DTO `SetCustomModulePreloadDto` mit `@IsBoolean() preload!: boolean` in `dto/custom-module.dto.ts`; Controller-Route `@Put(':id/preload')` direkt unter `getOne` (PUT, zwei Segmente — kein Verschatten; Kommentar dazu), `tenantId` nur aus `requireTenantId(req)`, kein `@Roles` (jeder angemeldete Benutzer, wie die übrigen Routen).
5. `docs/mandantentrennung-zugriffsklassifikation.md`: Zeile für das neue Paar `apps/api/src/custom-modules/custom-modules.service.ts | user` (Klasse und Stand nach dem Muster vorhandener `user`-Zeilen, gebunden über `forTenant(this.prisma, tenantId)`, nur eigene Zeile) ergänzen und die Bereichszeile `custom-modules` mit nachgemessenen Rohtreffern fortschreiben. `rls-access-inventory` und `rls-coverage` müssen grün sein.
Web-Schicht (D-03, D-04):
6. `custom-modules-api.ts`: `CustomModule` um optionales `preload?: boolean`; neue Funktion `setCustomModulePreload(id, preload)` → PUT `/custom-modules/{id}/preload`, `credentials: 'include'`, JSON-Körper `{ preload }`, Fehler über `failure(res)` (409 bleibt als `CustomModuleRequestError` mit Status 409 erkennbar). Test im bestehenden `custom-modules-api.test.ts`.
7. Neues `apps/web/src/lib/custom-module-preload.ts`: reiner Planer `startCustomModulePreload({ loadTargets, preloadOne, initialDelayMs = 2000, gapMs = 1500, idleTimeoutMs = 3000 })` → gibt `cancel()` zurück. Ablauf: setTimeout(initialDelayMs) → Leerlauf abwarten (`window.requestIdleCallback` mit `{ timeout: idleTimeoutMs }`, sonst setTimeout 0) → `loadTargets()` → für jedes Ziel nacheinander: Leerlauf abwarten → `preloadOne(ziel)` → gapMs warten. Nach `cancel()` keine Aufrufe mehr (auch nicht nach Auflösen eines laufenden `loadTargets`). Kopfkommentar: warum verzögert und gestaffelt (Start nicht verlangsamen).
8. Speicher `custom-module-cache-store.ts`: Zustand `pinned: string[]` (Invariante im Kommentar: jede gepinnte id ist auch in `openIds`) und Aktion `preload(mod)` laut behavior; `forget`/`clear` leeren auch `pinned`. Die Grenzen-Logik (acht, LRU ohne Gepinnte) kommt in Aufgabe 2 — hier nur so, dass `activate` gepinnte Module nicht aus `pinned` entfernt.
9. Neue Komponente `apps/web/src/components/modules/custom-module-preloader.tsx` (`'use client'`, rendert null): Effekt auf `useAuthStore` user.id; ohne Benutzer nichts. Mit Benutzer: `startCustomModulePreload` mit `loadTargets = listCustomModules()` gefiltert auf `preload === true` UND `checkCustomModuleUrl(url) === 'ok'`, auf `CUSTOM_MODULE_PRELOAD_MAX` gekürzt; `preloadOne = (m) => useCustomModuleCacheStore.getState().preload({ id, name, url })`; Fehler beim Listen still verschlucken (Vorladen ist Komfort). Aufräumen des Effekts ruft `cancel()` — deckt Abmelden (user null) und Benutzerwechsel ab (D-04); das Leeren der Rahmen bleibt beim Behälter/Kopfzeile wie bisher.
10. Behälter `custom-module-frame-host.tsx` (D-03): solange noch kein Platzhalter gemessen wurde (`rect === null`) und Rahmen offen sind, Ersatzgröße aus dem Inhaltsbereich messen: Element mit Attribut `data-app-shell-main` (in app-shell.tsx am `` setzen), `getBoundingClientRect` minus berechnete Innenabstände (`getComputedStyle`), Höhe = `window.innerHeight − top − Innenabstand unten`, mindestens 320; bei `resize` nachmessen, solange nur die Ersatzgröße gilt. Sobald ein Platzhalter aktiv ist, gilt wie bisher dessen Messung (Größenänderung eines iframes lädt nicht neu). Sichtbarkeitsregel, Sandbox, Attribute, Elternknoten und `key={id}` UNVERÄNDERT lassen. Kopfkommentar um einen Absatz „Vorladen“ ergänzen (Ersatzgröße, Übernahme ohne Neuladen über stabile openIds).
11. `app-shell.tsx`: `` bekommt `data-app-shell-main`; `` direkt vor `` mit Kurzkommentar (quick-261008-who).
12. E2E-Skript `.planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh` (bash, `set -euo pipefail`, Hilfsdateien per `mktemp`, am Ende aufräumen per trap): wartet bis zu 180 s auf `curl -sf http://localhost:3001/health`; meldet admin/admin123 an (Cookie-Datei); holt einen Kategorieschlüssel aus `GET /module-categories` (erstes Element, Feld `key`); legt neun persönliche Probe-Module „Probe who 1..9“ mit `https://example.com/?who=N` an; prüft: PUT preload true auf Nr. 1–8 → 200 mit `"preload":true`; Nr. 9 → 409; `GET /custom-modules` zeigt Nr. 1 mit `"preload":true` und Nr. 9 mit `"preload":false`; PUT preload false auf Nr. 1 → 200, danach Nr. 9 → 200; PUT mit `{"preload":"ja"}` → 400; PUT auf eine zufällige UUID → 404; löscht danach alle Probe-Module und prüft, dass keines mehr in der Liste steht; gibt am Ende `e2e preload api ok` aus. Vor dem Ausführen den Stack neu bauen: `docker compose up -d --build api web`.
Tests laut behavior schreiben (rot), dann umsetzen (grün). Commit: `feat(quick-261008-who): eigene Module beim Start vorladen — Durchstich` (explizite Pfade stagen).
pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec vitest run src/custom-modules rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run src/lib/custom-modules-api src/lib/custom-module-preload src/lib/stores/custom-module-cache-store src/components/modules && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "customModulePreloadIds" apps/api/prisma/schema.prisma && ls apps/api/prisma/migrations/*_user_custom_module_preload/migration.sql >/dev/null && grep -q "CustomModulePreloader" apps/web/src/components/layout/app-shell.tsx && grep -q "data-app-shell-main" apps/web/src/components/layout/app-shell.tsx && grep -q "CUSTOM_MODULE_PRELOAD_MAX = 8" packages/shared/src/index.ts && bash .planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh && echo "tracer ok"
Die Einstellung wird pro Benutzer gespeichert und in list/getOne geliefert (Grenze 8, 404 für Fremdes, bereinigte Liste); nach dem Start lädt Tessera die markierten Module verzögert, gestaffelt und unsichtbar in den Behälter; ein vorgeladener Rahmen wird beim ersten Öffnen als derselbe Knoten ohne Neuladen übernommen; Abmelden/Benutzerwechsel bricht ausstehendes Vorladen ab; alle genannten Tests und das E2E-Skript sind grün und committet.
Aufgabe 2: Grenze acht mit gepinnten Modulen, Schalter in der Modulansicht und Karte in den Einstellungen
apps/web/src/lib/stores/custom-module-cache-store.ts, apps/web/src/lib/stores/custom-module-cache-store.test.ts, apps/web/src/components/modules/custom-module-view.tsx, apps/web/src/components/modules/custom-module-view.test.tsx, apps/web/src/components/custom-modules/custom-module-preload-settings.tsx, apps/web/src/components/custom-modules/custom-module-preload-settings.test.tsx, apps/web/src/app/(portal)/settings/custom-modules/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json
Speicher (reine Funktion + Aktionen):
- `CUSTOM_MODULE_KEEP_ALIVE_MAX === 8` und `CUSTOM_MODULE_PRELOAD_MAX <= CUSTOM_MODULE_KEEP_ALIVE_MAX`.
- Ohne Gepinnte: das neunte verschiedene Modul verwirft das am längsten unbenutzte.
- 3 gepinnt + 5 übrige offen: ein sechstes übriges verwirft das älteste ÜBRIGE, nie ein gepinntes.
- 8 gepinnt: ein übriges X bleibt offen, solange es das aktive ist (9 Rahmen); ein weiteres übriges Y verwirft X; alle gepinnten bleiben.
- Ein gepinntes Modul aktivieren lässt es gepinnt und zählt es nicht unter den übrigen.
- `setPinned(id, false)` macht das Modul wieder verwerfbar (beim nächsten Aktivieren gilt die Grenze); `setPinned(id, true)` über der Grenze wird ignoriert; `openIds`-Reihenfolge bleibt in allen Fällen stabil (nur Herausfallen, kein Umsortieren).
Modulansicht:
- Der Schalter „Beim Start vorladen“ steht in der App-Leiste (Portal in HEADER_ACTIONS_SLOT_ID) neben „In neuem Tab öffnen“, mit `aria-pressed` gemäß `preload` aus getCustomModule; Klick ruft `setCustomModulePreload(id, !aktuell)`, setzt danach `aria-pressed` um und ruft `setPinned`.
- Antwort 409 → Hinweis `role="status"` mit dem Grenz-Text, `aria-pressed` bleibt false; anderer Fehler → Speicherfehler-Text, Zustand bleibt.
- Der bestehende Test zur Offenhalten-Grenze prüft jetzt acht statt fünf.
Einstellungen-Karte:
- Listet ALLE sichtbaren Module (gemeinsame und persönliche, Kennzeichnung „Für alle“ / „Persönlich“) mit Kontrollkästchen, Zähler „x von 8“; bei acht gewählten sind die übrigen Kästchen deaktiviert und ein Hinweis steht da; Umschalten ruft die API und aktualisiert Liste und Speicher (an → `preload(mod)`, aus → `setPinned(id, false)`); 409/Fehler zeigen den passenden Text und setzen das Kästchen zurück; zieht nach `bumpSidebarRefresh` (sidebarRefreshKey) neu.
1. Speicher (D-02): `CUSTOM_MODULE_KEEP_ALIVE_MAX = 8` (Kommentar zur Begründung von 5 auf 8 nachziehen: Nutzerentscheidung 08.10.2026). `touchLru` erhält einen vierten Parameter `pinned: readonly string[] = []`: neue Reihenfolge wie bisher; verworfen werden nur NICHT gepinnte Kennungen jenseits von `max(1, max − Anzahl gepinnter)`, das gerade berührte Modul nie; `recent` enthält danach alle nicht verworfenen. `activate` und `preload` (Aufgabe 1) wenden dieselbe Regel an (preload ohne `active` zu verändern; das aktive Modul wird nie verworfen). Neue Aktion `setPinned(id, on)`: an nur, wenn das Modul offen ist und die Grenze `CUSTOM_MODULE_PRELOAD_MAX` nicht überschritten wird; aus entfernt aus `pinned`, ohne den Rahmen zu schließen. Kopfkommentar der Datei um „Vorladen/Pinnen“ ergänzen und dort das Verhalten bei mehr als acht festhalten (Serverbegrenzung 409 mit Hinweis; Extremfall acht gepinnt + ein aktives = neun Rahmen) — per D-02.
2. Modulansicht `custom-module-view.tsx` (D-01): lokaler Zustand `preload` (Startwert `pinned.includes(id)`, danach aus `loaded.preload === true` der Hintergrund-Antwort; dabei `setPinned(id, loaded.preload === true)`). Knopf `type="button"`, Klasse wie der Link (`btn btn-appbar` bzw. Ersatz `btn btn-secondary` ohne App-Leiste), `aria-pressed`, schlichtes Inline-SVG (Stil wie das vorhandene Pfeil-Symbol, gefüllt bei an), Text `t('preload.toggle')`; während des Speicherns deaktiviert. Beide Elemente gemeinsam in einem Fragment ins Portal. Hinweiszeile `role="status"` (klein, gedämpft bzw. destructive bei Fehler) oberhalb des Platzhalters, bis zum nächsten Klick. Kopfkommentar ergänzen. Bestehenden Grenz-Test auf acht umstellen, neue Tests laut behavior.
3. Neue Karte `apps/web/src/components/custom-modules/custom-module-preload-settings.tsx` mit `SettingsSection` (Titel/Beschreibung aus `customModules.preload.settingsTitle`/`settingsDescription`, Zähler `count` mit Platzhaltern `{count}`/`{max}`), Liste über `listCustomModules()`, Neuladen bei `sidebarRefreshKey`. In `settings/custom-modules/page.tsx` unter dem `CustomModuleManager` einhängen (Abstand wie andere Karten, z. B. `mt-6`); bestehenden Seitentest nur anpassen, falls er durch den zusätzlichen Listenaufruf bricht.
4. Texte (Sie-Form) unter `customModules.preload` in de.json UND en.json, gleiche Schlüssel: `toggle` („Beim Start vorladen“ / „Preload at startup“), `limitReached` (de sinngemäß: „Es können höchstens {max} eigene Module beim Start vorgeladen werden. Schalten Sie das Vorladen zuerst bei einem anderen Modul aus.“), `saveError` („Die Einstellung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.“), `settingsTitle` („Beim Start vorladen“), `settingsDescription` (de sinngemäß: ausgewählte Module lädt Tessera kurz nach dem Start unsichtbar im Hintergrund, damit sie beim ersten Öffnen sofort da sind; höchstens {max}; gilt im Browser und in der Desktop-App), `count` („{count} von {max} ausgewählt“), `shared` („Für alle“), `personal` („Persönlich“), `empty` („Es gibt noch keine eigenen Module.“). Keine Begriffe der Mehrfirmen-Trennung oder Freischaltung (D-01; das Prüfskript in verify schlägt sonst an). Typografisch korrekte Anführungszeichen, keine Ersatzschreibungen für Umlaute.
Tests zuerst (rot), dann umsetzen. Commit: `feat(quick-261008-who): Vorladen schalten, Grenze acht mit gepinnten Modulen`.
pnpm --filter @tessera/web exec vitest run src/lib/stores/custom-module-cache-store src/components/modules src/components/custom-modules settings/custom-modules src/messages && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "CUSTOM_MODULE_KEEP_ALIVE_MAX = 8" apps/web/src/lib/stores/custom-module-cache-store.ts && grep -q "CustomModulePreloadSettings" "apps/web/src/app/(portal)/settings/custom-modules/page.tsx" && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.customModules&&de.customModules.preload,"p",{}),b=w(en.customModules&&en.customModules.preload,"p",{});if(!Object.keys(a).length||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const k of ["toggle","limitReached","saveError","settingsTitle","settingsDescription","count","shared","personal","empty"])if(!a["p."+k]){console.error("missing",k);process.exit(1)}if(a["p.toggle"]!=="Beim Start vorladen"){console.error("label");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens/i.test(String(v))){console.error("bad text",v);process.exit(1)}console.log("i18n ok")' && echo "task2 ok"
Offenhalten-Grenze acht, gepinnte Module werden nie verworfen, Vorladen auf acht begrenzt mit Hinweis; Schalter in der App-Leiste der Modulansicht und Karte „Beim Start vorladen“ in Einstellungen → Eigene Module funktionieren für persönliche und gemeinsame Module; Texte de/en vollständig; Tests grün und committet.
Aufgabe 3: Anleitung, CHANGELOG, Gesamtprüfung und Browser-Nachweis
docs/anleitung-anwender.md, CHANGELOG.md
1. `docs/anleitung-anwender.md` (D-05): direkt nach dem Absatz „**Eigene Module:**“ im Einstellungen-Abschnitt (bei Planung Zeile ~319) einen neuen Absatz „**Offen halten und beim Start vorladen:**“ in ganzen Sätzen, Sie-Form: einmal geöffnete eigene Module bleiben geöffnet, bis Sie die Seite neu laden oder die Desktop-App neu starten (Scrollstand, Anmeldung auf der Seite und Eingaben bleiben erhalten); bis zu acht gleichzeitig, danach schließt Tessera das am längsten nicht benutzte; „Beim Start vorladen“ schalten Sie in der Leiste oben in der Modulansicht oder in Einstellungen → Eigene Module in der Karte „Beim Start vorladen“ — auch für Einträge, die der Administrator für alle angelegt hat; die Wahl gilt nur für Sie, im Browser und in der Desktop-App gleich; Tessera startet zuerst vollständig und lädt die gewählten Module kurz danach nacheinander unsichtbar, ein Klick zeigt sie dann sofort; höchstens acht Module, beim neunten erscheint ein Hinweis; vorgeladene Module schließt Tessera nie von selbst; beim Abmelden werden alle geschlossen; Seiten, die das Einbetten verbieten, bleiben auch vorgeladen leer. Den bestehenden Satz zu den Einträgen des Administrators nicht widersprüchlich lassen (dort bleibt: hier nicht änderbar — das Vorladen ist davon ausgenommen).
2. `docs/anleitung-entwicklung.md` (D-05): nur ergänzen, falls dort die Architektur der eigenen Module / des Rahmen-Behälters beschrieben ist (bei Planung per grep: NICHT der Fall). Bei Ausführung erneut prüfen (`grep -n -i "custom-module\|eigene Module\|iframe" docs/anleitung-entwicklung.md`); ohne Fundstelle die Datei unverändert lassen und das im SUMMARY festhalten.
3. `CHANGELOG.md`, Abschnitt „## Unveröffentlicht“ (D-05): unter „### Neu“ ein Punkt „Eigene Module beim Start vorladen: …“ (Schalter je Modul in der Modulansicht und in den Einstellungen, pro Benutzer gespeichert, gilt in Browser und Desktop-App, lädt kurz nach dem Start unsichtbar nacheinander, höchstens acht); unter „### Geändert“ ein Punkt: Tessera hält jetzt bis zu acht eigene Module offen (vorher fünf), vorgeladene werden nie geschlossen. Vorhandene Unterüberschriften nutzen, nichts Bestehendes umformulieren. Modul-Changelog (261008-w5w): eigene Module stehen nicht im Modul-Register, w5w liefert für sie eine leere Liste — daher KEIN Modul-Changelog-Eintrag; im SUMMARY vermerken.
4. Gesamtprüfung: vollständige Testläufe API und Web, beide `tsc --noEmit`, `pnpm exec biome lint` auf alle in diesem Plan geänderten bzw. neuen .ts/.tsx-Dateien (Fehler beheben).
5. Browser-Nachweis mit den Playwright-MCP-Werkzeugen, Dunkelmodus (über den Theme-Knopf umschalten), Ablage unter `.playwright-mcp/custom-module-preload/` (nicht committen). Vorher `docker compose up -d --build api web` und auf die API warten. Vorbereitung per curl (admin/admin123, Kategorieschlüssel aus GET /module-categories): zwei persönliche Module „Probe who A“ (`https://example.com/?who=a`) und „Probe who B“ (`https://example.com/?who=b`) mit preload true, ein drittes „Probe who C“ ohne. Ablauf: (a) http://localhost:3000 anmelden, Dashboard öffnen; (b) Seite neu laden (entspricht Start mit bestehender Sitzung) und SOFORT per `browser_evaluate` eine Messung starten, die 10 s lang alle 100 ms die Knoten `[data-testid="custom-module-frame"]` erfasst und je `data-custom-module-id` den Zeitpunkt des ersten Auftauchens (`performance.now()`), deren `style.visibility` und Breite festhält; zusätzlich `performance.getEntriesByType('navigation')[0].loadEventEnd` und die Einträge `performance.getEntriesByType('resource')` mit `initiatorType === 'iframe'` (Startzeiten) — NICHT per fetch aus der Seite messen; (c) vor dem Klick Rahmen A in `window.__probe` merken, `window.__loads = 0` und einen `load`-Zuhörer an Rahmen A hängen, der hochzählt; dann in der Seitenleiste „Probe who A“ anklicken, 2 s warten, prüfen: Anzahl Rahmen 2, `document.querySelector('[data-custom-module-id=""]') === window.__probe`, `window.__loads === 0`, Rahmen A sichtbar; Bildschirmfoto `t3-dark-preloaded-open.png`; (d) Messwerte als JSON nach `.playwright-mcp/custom-module-preload/t3-measure.json` schreiben mit den Feldern `frameCountAtStart`, `allHiddenAtStart`, `allWidthPositiveAtStart`, `firstFrameAppearMs`, `gapBetweenFramesMs`, `loadEventEndMs`, `sameNodeAfterClick`, `loadsAfterClick`, `visibleAfterClick`, `notPreloadedFramePresent` (C darf beim Start NICHT auftauchen); (e) abmelden, prüfen, dass nach erneuter Anmeldung nur A und B wieder vorgeladen werden; (f) Probe-Module per curl löschen. Weicht ein Wert ab (z. B. Rahmen vor dem Ende des Seitenladens, Neuladen beim Klick), Ursache beheben statt Schwelle anpassen.
Commit: `docs(quick-261008-who): Vorladen eigener Module in Anleitung und CHANGELOG` (explizite Pfade; nichts aus `gespraech-2026-11/` oder `.playwright-mcp/`).
pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && sed -n '/^## Unveröffentlicht/,/^## [0-9]/p' CHANGELOG.md | grep -qi "vorladen" && sed -n '/^## Unveröffentlicht/,/^## [0-9]/p' CHANGELOG.md | grep -q "acht" && grep -q "Beim Start vorladen" docs/anleitung-anwender.md && grep -q "Offen halten und beim Start vorladen" docs/anleitung-anwender.md && test -f .playwright-mcp/custom-module-preload/t3-dark-preloaded-open.png && node -e 'const m=require("./.playwright-mcp/custom-module-preload/t3-measure.json");const ok=m.frameCountAtStart===2&&m.allHiddenAtStart===true&&m.allWidthPositiveAtStart===true&&m.firstFrameAppearMs>=1500&&m.firstFrameAppearMs>m.loadEventEndMs&&m.gapBetweenFramesMs>=1000&&m.sameNodeAfterClick===true&&m.loadsAfterClick===0&&m.visibleAfterClick===true&&m.notPreloadedFramePresent===false;if(!ok){console.error(JSON.stringify(m));process.exit(1)}console.log("browser ok")' && echo "task3 ok"
Anwenderanleitung und CHANGELOG beschreiben Offenhalten, Vorladen und die Grenze acht; Entwicklungsanleitung geprüft (nur bei vorhandener Architekturbeschreibung ergänzt); alle Tests und Typprüfungen grün; der Browser-Nachweis belegt verzögertes, gestaffeltes, unsichtbares Vorladen und die Übernahme beim ersten Klick ohne Neuladen; Doku-Commit liegt vor, nichts gepusht.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API (PUT /custom-modules/:id/preload) | Kennung und Körper vom Client, nicht vertrauenswürdig |
| Datenbank → Web (Adresse eigener Module) | Adresse wird als iframe-src im Portal gerendert, auch ohne Klick des Benutzers |
| Benutzer A → Benutzer B (selbes Gerät/Fenster) | Abmelden/Benutzerwechsel ohne Neuladen der Seite |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-WHO-01 | Elevation of Privilege | CustomModulesService.setPreload | high | mitigate | Sichtbarkeit über `loadVisible` (fremde persönliche/fehlende Kennung → 404 ohne Schreiben); geschrieben wird ausschließlich die eigene Benutzerzeile `where: { id: caller.id }` über `forTenant(this.prisma, tenantId)`; kein Benutzer-Kennungsparameter; Tests in custom-modules.service.spec.ts |
| T-WHO-02 | Information Disclosure | list/getOne, Benutzerantworten | medium | mitigate | `preload` wird je Aufrufer aus der eigenen Zeile berechnet; neue Spalte nicht in /auth/me oder Benutzerlisten ausgegeben (Prüfung in Aufgabe 1, Schritt 2); Test Benutzer A/B |
| T-WHO-03 | Denial of Service | Benutzerzeile / Browser | medium | mitigate | Serverseitige Grenze `CUSTOM_MODULE_PRELOAD_MAX` (409), Bereinigung auf sichtbare Kennungen bei jedem Schreiben (Liste wächst nicht); Web kürzt auf acht und staffelt im Leerlauf, Offenhalten-Gesamtgrenze acht |
| T-WHO-04 | Tampering | Vorlade-Komponente / Behälter (iframe-src) | high | mitigate | Vorladen nur bei `checkCustomModuleUrl(url) === 'ok'` (Komponente UND Behälter wie T-9WC-03), unveränderte Sandbox `XFRAME_SANDBOX`, `allow=""`, `referrerPolicy="no-referrer"`; Test mit http-Adresse wird nie vorgeladen |
| T-WHO-05 | Information Disclosure | Abmelden/Benutzerwechsel | high | mitigate | Effekt-Aufräumen bricht ausstehendes Vorladen ab, bestehendes `clear()` in Kopfzeile/Behälter verwirft alle Rahmen und `pinned`; Tests in custom-module-preloader.test.tsx und Browser-Schritt (e) |
| T-WHO-06 | Information Disclosure | Drittseiten erhalten Aufrufe beim Start | low | accept | Der Benutzer schaltet das Vorladen je Modul bewusst ein; kein Referrer, gleiche Sandbox wie beim manuellen Öffnen |
| T-WHO-SC | Tampering | npm/pip/cargo installs | high | accept | Dieser Plan installiert keine Pakete; `@tessera/shared` ist ein vorhandenes Workspace-Paket |
- Aufgabe 1: API-Tests (inkl. rls-access-inventory/rls-coverage), Web-Tests für Planer, Vorlade-Komponente, Behälter-Übernahme, Speicher; E2E-Skript gegen den neu gebauten Stack.
- Aufgabe 2: Speicher-LRU mit Gepinnten und Grenze acht, Schalter in Modulansicht und Einstellungen, i18n-Prüfskript.
- Aufgabe 3: Vollständige Testläufe, Typprüfungen, Lint der geänderten Dateien, Doku-Greps, Browser-Messwerte aus t3-measure.json.
- Ein Benutzer markiert bis zu acht eigene Module (persönlich oder gemeinsam) für das Vorladen; ein anderer Benutzer bleibt unberührt.
- Nach Anmeldung bzw. Neuladen erscheinen die Rahmen der markierten Module erst nach dem Seitenladen (frühestens ca. 1,5–2 s), nacheinander (Abstand ≥ 1 s), unsichtbar und mit sinnvoller Größe.
- Der erste Klick zeigt den vorgeladenen Rahmen ohne Neuladen (selber Knoten, 0 load-Ereignisse).
- Grenze acht offen; vorgeladene werden nie verworfen; neunter Vorladeversuch → Hinweis.
- Abmelden verwirft alles; nicht markierte, gelöschte oder fremde Module werden nie vorgeladen.
- Anwenderanleitung und CHANGELOG „Unveröffentlicht“ aktualisiert; kein Modul-Changelog-Eintrag (eigene Module stehen nicht im Register).