Files
tessera-ctl/.planning/quick/261008-who-eigene-module-beim-start-vorladen/261008-who-PLAN.md
T
schalli 41a57543f9
Tessera CI/CD / Lint & Type Check (push) Successful in 1m9s
Tessera CI/CD / Tests (push) Successful in 2m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m41s
docs(quick-261008-who): Eigene Module vorladen; Auftrag Cert-Manager-Umbau
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:25:59 +02:00

281 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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: "<CustomModulePreloader /> neben <CustomModuleFrameHost />"
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"
---
<objective>
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).
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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) <noreply@anthropic.com>`
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.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1 (Durchstich): Einstellung speichern → nach dem Start unsichtbar vorladen → erster Klick übernimmt den Rahmen</name>
<files>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</files>
<behavior>
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`.
</behavior>
<action>
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<T>({ 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 `<main>` 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`: `<main>` bekommt `data-app-shell-main`; `<CustomModulePreloader />` direkt vor `<CustomModuleFrameHost />` 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).
</action>
<verify>
<automated>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"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Grenze acht mit gepinnten Modulen, Schalter in der Modulansicht und Karte in den Einstellungen</name>
<files>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</files>
<behavior>
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.
</behavior>
<action>
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`.
</action>
<verify>
<automated>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"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Anleitung, CHANGELOG, Gesamtprüfung und Browser-Nachweis</name>
<files>docs/anleitung-anwender.md, CHANGELOG.md</files>
<action>
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="<A>"]') === 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/`).
</action>
<verify>
<automated>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"</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
- 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.
</verification>
<success_criteria>
- 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).
</success_criteria>
<output>
Create `.planning/quick/261008-who-eigene-module-beim-start-vorladen/261008-who-SUMMARY.md` when done (mit: gewählter Speicherort und Begründung, tatsächlicher Migrations-Zeitstempel, Verhalten bei mehr als acht, Ergebnis der Prüfung docs/anleitung-entwicklung.md, Modul-Changelog-Entscheidung, Browser-Messwerte aus t3-measure.json)
</output>