--- phase: quick-260929-9wc plan: 01 quick_id: 260929-9wc type: execute wave: 1 depends_on: [] autonomous: true requirements: [QUICK-260929-9wc] files_modified: - packages/shared/src/index.ts - apps/api/prisma/schema.prisma - apps/api/prisma/migrations/20260929120000_custom_module/migration.sql (neu) - apps/api/src/custom-modules/dto/custom-module.dto.ts (neu) - apps/api/src/custom-modules/dto/custom-module.dto.spec.ts (neu) - apps/api/src/custom-modules/custom-modules.service.ts (neu) - apps/api/src/custom-modules/custom-modules.service.spec.ts (neu) - apps/api/src/custom-modules/custom-modules.controller.ts (neu) - apps/api/src/custom-modules/custom-modules.controller.spec.ts (neu) - apps/api/src/custom-modules/custom-modules.module.ts (neu) - apps/api/src/app.module.ts - docs/mandantentrennung-zugriffsklassifikation.md - apps/web/src/lib/custom-modules-api.ts (neu) - apps/web/src/lib/custom-modules-api.test.ts (neu) - apps/web/src/lib/stores/nav-store.test.ts (neu) - apps/web/src/components/layout/sidebar.tsx - apps/web/src/components/layout/sidebar.test.tsx - apps/web/src/components/modules/custom-module-view.tsx (neu) - apps/web/src/components/modules/custom-module-view.test.tsx (neu) - apps/web/src/app/(portal)/modules/custom/[id]/page.tsx (neu) - apps/web/src/app/(portal)/admin/custom-modules/page.tsx (neu) - apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx (neu) - apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx (neu) - apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx (neu) - apps/web/src/components/admin/admin-sidebar.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json - apps/web/src/messages/umlaut-dictionary.ts - apps/web/src/messages/module-categories.spec.ts (neu) - CHANGELOG.md estimate: tokens: 150000 raw_tokens: 150000 tasks: 3 confidence: low must_haves: truths: - "Ein Administrator legt unter Verwaltung > Eigene Module einen Eintrag mit Name, https-Adresse und einer der fünf Seitenleisten-Kategorien an, ändert ihn und löscht ihn (D-01, D-07)" - "Jeder angemeldete Benutzer sieht jedes eigene Modul als Eintrag unter der gewählten Kategorie in der Seitenleiste (auch eingeklappt und in der Suche); nach Anlegen, Ändern oder Löschen zieht die Seitenleiste ohne Neuladen nach (D-01, D-05)" - "Ein Klick öffnet /modules/custom/: ein eingebetteter Rahmen füllt den Inhaltsbereich mit exakt dem Sandbox-Wert XFRAME_SANDBOX und referrerPolicy no-referrer, darüber steht immer sichtbar der Knopf „In neuem Tab öffnen“ (echter Link, target _blank, rel noopener noreferrer); die Kopfzeile zeigt den Namen des Eintrags (D-06)" - "Eine Adresse, die nicht https ist oder Zugangsdaten enthält, lehnt die API mit 400 und das Formular mit einer Meldung ab; eine solche Adresse wird nie als Rahmen oder Link gerendert (D-04, D-06)" - "POST/PATCH/DELETE /custom-modules sind nur für ADMIN und SUPER_ADMIN offen (sonst 403), GET /custom-modules und GET /custom-modules/:id für jeden angemeldeten Benutzer, ohne Anmeldung 401 (D-04)" - "Die Tabelle CustomModule trägt tenantId, ENABLE/FORCE ROW LEVEL SECURITY und tenant_isolation_policy; jeder Zugriff im Dienst läuft über `const tenantPrisma = forTenant(this.prisma, tenantId)`; rls-coverage.spec.ts und rls-access-inventory.spec.ts sind grün (D-03)" - "Alle neuen Texte stehen deutsch (Sie-Form) und englisch; CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“ in Alltagssprache (D-08, D-09)" artifacts: - path: "apps/api/prisma/migrations/20260929120000_custom_module/migration.sql" provides: "Tabelle CustomModule mit tenantId, Index, RLS ENABLE/FORCE, tenant_isolation_policy ohne Benutzerdimension, ohne system_read_policy" - path: "apps/api/src/custom-modules/custom-modules.controller.ts" provides: "GET '' und GET ':id' (jeder Angemeldete), POST/PATCH ':id'/DELETE ':id' mit @Roles(ADMIN, SUPER_ADMIN); list vor getOne deklariert" - path: "apps/api/src/custom-modules/custom-modules.service.ts" provides: "list/getOne/create/update/remove, je Methode ein forTenant-Klient, Fremd-Mandant oder unbekannte id -> NotFoundException" - path: "apps/api/src/custom-modules/dto/custom-module.dto.ts" provides: "CreateCustomModuleDto/UpdateCustomModuleDto: Name 1-100 Zeichen, Adresse nur https ohne Zugangsdaten max 2048, Kategorie @IsIn(MODULE_CATEGORIES)" - path: "packages/shared/src/index.ts" provides: "MODULE_CATEGORIES = ['domain-tools','security-tools','fleet','infrastructure','procurement'] + Typ ModuleCategory" - path: "apps/web/src/lib/custom-modules-api.ts" provides: "CustomModule-Typ, listCustomModules/getCustomModule/createCustomModule/updateCustomModule/deleteCustomModule, checkCustomModuleUrl" - path: "apps/web/src/components/modules/custom-module-view.tsx" provides: "Rahmen-Ansicht mit Leiste (Name, Hinweis, „In neuem Tab öffnen“) und Vollflächen-iframe" - path: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" provides: "Verwaltungsseite: Liste, Anlegen/Bearbeiten (Formular-Dialog), Löschen (Bestätigung)" key_links: - from: "apps/web/src/components/layout/sidebar.tsx" to: "GET /custom-modules" via: "listCustomModules() im selben Effekt wie /modules/active, ausgelöst durch sidebarRefreshKey" pattern: "listCustomModules" - from: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" to: "apps/web/src/components/layout/sidebar.tsx" via: "useMarketplaceStore bumpSidebarRefresh() nach jedem erfolgreichen Speichern/Löschen" pattern: "bumpSidebarRefresh" - from: "apps/web/src/components/modules/custom-module-view.tsx" to: "apps/web/src/components/dashboard/widgets/xframe-config.ts" via: "Import XFRAME_SANDBOX — ein Sandbox-Wert für XFrame und eigene Module" pattern: "XFRAME_SANDBOX" - from: "apps/api/src/custom-modules/custom-modules.service.ts" to: "apps/api/src/prisma/prisma-tenant.extension.ts" via: "const tenantPrisma = forTenant(this.prisma, tenantId)" pattern: "const tenantPrisma = forTenant\\(this\\.prisma, tenantId\\)" - from: "apps/api/src/app.module.ts" to: "apps/api/src/custom-modules/custom-modules.module.ts" via: "imports: [..., CustomModulesModule]" pattern: "CustomModulesModule" - from: "docs/mandantentrennung-zugriffsklassifikation.md" to: "apps/api/src/prisma/rls-access-inventory.spec.ts" via: "Bestandsaufnahme-Zeile custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden" pattern: "custom-modules.service.ts \\| customModule" --- # Quick 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Einträge Nutzerauftrag (29.09.): Der Administrator legt Seitenleisten-Einträge an, die externe Seiten per eingebettetem Rahmen in Tessera zeigen. ## Festgelegte Punkte (mit dem Nutzer entschieden, nicht verhandelbar) - **D-01** Der Admin legt Einträge an mit Name, https-Adresse und Seitenleisten-Kategorie (eine der bestehenden Kategorien). Einträge sind für ALLE Benutzer sichtbar. - **D-02** Einschränkung auf Gruppen NUR, wenn der bestehende ModuleGrant/Gruppen-Mechanismus das mit sehr wenig Aufwand hergibt — sonst weglassen und als zurückgestellt notieren. **Entscheidung beim Planen: zurückgestellt.** Begründung (gemessen im Schema): `ModuleGrant.moduleId` ist ein Pflicht-Fremdschlüssel auf `Module` (`onDelete: Cascade`), eigene Module sind keine `Module`-Zeilen. Eine Einschränkung bräuchte eine neue Freigabetabelle oder einen Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberfläche — das ist nicht „sehr wenig Aufwand“. Im SUMMARY unter „Bewusst offen“ notieren; im Code nichts dafür bauen. - **D-03** Prisma-Modell `CustomModule` + Migration MIT Zeilenschutz nach Muster `ProxmoxServer` (tenantId-Spalte, Regel, prisma-tenant-Erweiterung); RLS-Inventar-Test und `docs/mandantentrennung-zugriffsklassifikation.md` fortschreiben. - **D-04** API: GET-Liste für jeden angemeldeten Benutzer; POST/PATCH/DELETE nur Admin; Adresse nur https. - **D-05** Seitenleiste: jedes eigene Modul erscheint als Eintrag unter seiner Kategorie. - **D-06** Seite `/modules/custom/[id]`: Rahmen über die ganze Fläche genau wie das XFrame-Widget (derselbe Sandbox-Wert ohne Navigation des obersten Fensters, `referrerPolicy="no-referrer"`, nur https) PLUS immer sichtbarer Knopf „In neuem Tab öffnen“ (viele Seiten verbieten das Einbetten). - **D-07** Verwaltungsoberfläche im Admin-Bereich: einfache Liste + Anlegen/Bearbeiten/Löschen im Stil der bestehenden Admin-Seiten (Vorbild `admin/groups`). - **D-08** Texte deutsch und englisch; App-Texte im Deutschen in Sie-Form. - **D-09** CHANGELOG unter „Unveröffentlicht“ > „Neu“, Alltagssprache für Nicht-Programmierer. - **D-10** Tests: API-Dienst/Controller, Web-Komponenten, RLS-Inventar. Statische GET-Routen stehen im Controller VOR `@Get(':id')`. - **D-11** Abschluss: Browser-Prüfung mit Playwright MCP am lokalen Stack (web :3000, api :3001, admin / admin123) im DUNKELMODUS (Umschalten über den Theme-Knopf der Kopfzeile, nie per classList). Migration vom Host über die Container-IP (172.19.x, `tessera:tessera_dev`), danach `docker compose up -d --build web api`. - **D-12** Nur lokal committen, NIEMALS `git push`. ## Grundlagen (wiederverwenden, nicht neu erfinden) - **Kategorien**: Die Seitenleiste gruppiert nach `Module.category`; im Einsatz sind genau fünf Kennungen aus den Seeds (`domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement`), deren Anzeigenamen in `moduleCategories` von `de.json`/`en.json` stehen und über `useCategoryLabel()` aufgelöst werden. Neu: diese Liste einmal als `MODULE_CATEGORIES` in `packages/shared/src/index.ts` — die API prüft per `@IsIn`, das Formular baut daraus die Auswahl. - **Zeilenschutz-Vorbild**: `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql` (Kopfkommentar-Pflicht, `ENABLE`/`FORCE`, `tenant_isolation_policy` OHNE Benutzerdimension, weil Verwaltungsdaten des Mandanten). KEINE `system_read_policy` — es gibt keinen Hintergrunddienst. - **API-Vorbild**: `apps/api/src/proxmox/proxmox.controller.ts` (`requireTenantId(req)`, `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenantId nur aus `req.tenantId`) und `proxmox.service.ts` (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId);`). Globale Wächter JwtAuthGuard/TenantGuard/RolesGuard stehen in `app.module.ts`; ValidationPipe mit `whitelist: true, transform: true` in `main.ts`. - **Rahmen-Vorbild**: `apps/web/src/components/dashboard/widgets/xframe-config.ts` (`XFRAME_SANDBOX`, Begründung im Dateikopf; `isHttpsUrl` aus `picture-frame-config.ts`) und `xframe-widget.tsx` (`frameAttrs` mit `allow: ''`, `referrerPolicy: 'no-referrer'`; `NewTabLink` als echter Link). - **Seitenleiste**: `apps/web/src/components/layout/sidebar.tsx` lädt `/modules/active`, gruppiert nach Kategorie, Auffrischung über `useMarketplaceStore` `sidebarRefreshKey`/`bumpSidebarRefresh`; sie veröffentlicht die Liste in `useNavStore`, aus der `resolvePageTitle` den Kopfzeilen-Titel über Pfadsegment == `slug` findet. - **Routen**: Der statische Ordner `modules/custom/[id]` hat im App Router Vorrang vor `modules/[category]/[moduleSlug]` — kein Konflikt. ## Verbindliche Regeln für alle Aufgaben - `de.json` mit echten Umlauten. `umlaut-guard.spec.ts` meldet jedes NEUE deutsche Wort mit ae/oe/ue/ss, das noch nicht auf der Liste steht (etwa „Adressen“ oder „müssen“) — ist es korrektes Deutsch, gehört es in `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts`. Jeder neue Schlüssel in `de.json` UND `en.json` (Schlüssel-Gleichheit wird geprüft). - Keine Großbuchstaben-Etiketten, keine Mittelpunkt-Ketten, kein Pfeilzeichen in Texten oder Knöpfen (Stil der letzten Quick-Aufträge). Keine neuen Pakete. - Biome-Grundlinie gemessen am 29.09.: Web 55 Warnungen, API 82 — darf nicht steigen. - Die bereits vorgemerkten Löschungen `.planning/.continue-here.md` und `.planning/HANDOFF.json` (Sitzungsübergabe) nicht wiederherstellen. - Commits nur lokal. Kein `git push`, auch nicht am Ende (D-12). Administratoren binden externe Webseiten als „Eigene Module“ in die Seitenleiste ein: Name, https-Adresse, Kategorie. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick zeigt die Seite in einem abgesicherten, flächenfüllenden Rahmen mit immer sichtbarem „In neuem Tab öffnen“. Die Daten liegen mandantengetrennt mit Zeilenschutz in der Tabelle `CustomModule` (D-01 bis D-12; D-02 Gruppen-Einschränkung bewusst zurückgestellt). Purpose: Werkzeuge, für die es (noch) kein eigenes Tessera-Modul gibt, sind trotzdem aus der zentralen Plattform heraus erreichbar — der Kernnutzen „nicht zwischen Anwendungen wechseln“. Output: Tabelle + Migration mit Zeilenschutz, API `/custom-modules`, Seitenleisten-Einträge, Rahmen-Seite, Verwaltungsseite, Texte de/en, Tests, fortgeschriebene Zugriffsklassifikation, CHANGELOG. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/STATE.md @./CLAUDE.md @apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql @apps/api/src/proxmox/proxmox.controller.ts @apps/web/src/components/dashboard/widgets/xframe-config.ts @apps/web/src/components/layout/sidebar.tsx Aufgabe 1 (Tracer): Ein eigenes Modul von der Datenbank bis in Seitenleiste und Rahmen-Seite packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929120000_custom_module/migration.sql, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/dto/custom-module.dto.spec.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/custom-modules.module.ts, apps/api/src/app.module.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/stores/nav-store.test.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/components/modules/custom-module-view.tsx, apps/web/src/components/modules/custom-module-view.test.tsx, apps/web/src/app/(portal)/modules/custom/[id]/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, apps/web/src/messages/module-categories.spec.ts Der lokale Stack läuft: `docker compose ps --format '{{.Service}} {{.State}}'` zeigt db, api und web als running. apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql, apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox.service.ts (nur createServer/updateServer/deleteServer, Zeilen 120-240), apps/api/src/proxmox/proxmox.service.spec.ts (Kopf bis makeFakePrisma), apps/api/src/tenders/tenders.controller.spec.ts (Reihenfolge-Test ab Zeile 365), apps/api/src/prisma/rls-access-inventory.spec.ts (Zeilen 1-80 und parseDocEntries), apps/web/src/components/dashboard/widgets/xframe-widget.tsx (Zeilen 95-115 und NewTabLink), apps/web/src/lib/favorites-api.test.ts (Kopf), apps/web/src/components/layout/sidebar.test.tsx - DTO: `https://example.com` mit Kategorie `infrastructure` und Name „Wiki“ ist gültig; `http://example.com`, `javascript:alert(1)`, `data:text/html,x`, `ftp://x`, unparsbarer Text, `https://user:pw@example.com` sind ungültig; Kategorie `other` ist ungültig; leerer oder nur aus Leerzeichen bestehender Name ist ungültig; Name über 100 und Adresse über 2048 Zeichen sind ungültig; Update-DTO akzeptiert Teilmengen, prüft aber jedes gesetzte Feld gleich - Dienst: create speichert tenantId aus dem Argument (nie aus dem DTO); list liefert nur Zeilen des Mandanten, nach Name sortiert; getOne/update/remove mit unbekannter id oder Zeile eines anderen Mandanten -> NotFoundException; forTenant wird je Methode mit (prisma, tenantId) aufgerufen - Controller: create/update/remove tragen ROLES_KEY [ADMIN, SUPER_ADMIN], list/getOne tragen keine Rollen; fehlendes req.tenantId -> ForbiddenException; tenantId kommt aus req.tenantId; `list` ist vor `getOne` deklariert - Web-Client: checkCustomModuleUrl('https://a.de') = 'ok', 'http://a.de' = 'notHttps', 'https://u:p@a.de' = 'credentials', 'kaputt' = 'notHttps'; listCustomModules ruft GET {API}/custom-modules mit credentials include - Seitenleiste: ein eigenes Modul mit Kategorie `infrastructure` erscheint unter dieser Kategorie als Link auf /modules/custom/; eine Kategorie, die nur eigene Module hat, erscheint trotzdem; auf /modules/custom/ trägt genau dieser Eintrag die Auswahlmarke; die bestehenden Abruf-Zählertests bleiben unverändert grün - Kopfzeilen-Titel: resolvePageTitle('/modules/custom/abc', [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }]) liefert { text: 'Wiki' } - Rahmen-Ansicht: rendert iframe mit src = Adresse, title = Name, sandbox exakt XFRAME_SANDBOX (enthält kein top-navigation-Token), referrerpolicy no-referrer, allow leer; der Link „In neuem Tab öffnen“ ist sichtbar mit href = Adresse, target _blank, rel „noopener noreferrer“; bei nicht gültiger Adresse kein iframe und kein Link, stattdessen Hinweistext; bei 404 der Nicht-gefunden-Text - Kategorien-Gleichlauf: jede Kennung aus MODULE_CATEGORIES hat einen Schlüssel in moduleCategories von de.json und en.json Tests zuerst schreiben (rot), dann bauen (grün). Reihenfolge der Arbeit: 1. Gemeinsame Kategorienliste (D-01): in `packages/shared/src/index.ts` `MODULE_CATEGORIES` als `as const`-Liste der fünf Kennungen `domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement` plus `export type ModuleCategory`, mit kurzem Kommentar, dass die Liste den Seed-Kategorien der Module und den Schlüsseln `moduleCategories` in den Übersetzungen entspricht. Neue Spec `apps/web/src/messages/module-categories.spec.ts` prüft den Gleichlauf mit `de.json` und `en.json`. 2. Datenbank (D-03): in `apps/api/prisma/schema.prisma` hinter `ProxmoxServerStatus` das Modell `CustomModule` mit `id String @id @default(uuid())`, `tenantId String`, `name String`, `url String`, `category String` (Kommentar: eine der MODULE_CATEGORIES), `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId])` — ohne Relation zu Tenant (Muster ProxmoxServer). Migration `apps/api/prisma/migrations/20260929120000_custom_module/migration.sql` von Hand nach Vorbild 20260923140000: deutscher Kopfkommentar (Zweck, Zeilenschutz OHNE Benutzerdimension weil Verwaltungsdaten des Mandanten, bewusst KEINE system_read_policy weil kein Hintergrunddienst, Rechte für tessera_app kommen über ALTER DEFAULT PRIVILEGES, Hinweis dass die Regeln erst mit der Anwendungsrolle wirken), dann CREATE TABLE "CustomModule" mit den Spalten in Prisma-Form (TIMESTAMP(3), updatedAt ohne Default), Primärschlüssel "CustomModule_pkey", Index "CustomModule_tenantId_idx", `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` und `CREATE POLICY tenant_isolation_policy ON "CustomModule" USING ("tenantId" = current_tenant_id());`. Danach `pnpm --filter @tessera/api exec prisma generate`. 3. DTO `apps/api/src/custom-modules/dto/custom-module.dto.ts` (D-04): `CreateCustomModuleDto` mit `name` (`@Transform` trimmt Zeichenketten, `@IsString`, `@IsNotEmpty`, `@MaxLength(100)`), `url` (`@IsString`, `@MaxLength(2048)`, eigene `@ValidatorConstraint` nach Muster `PmgOhneTokenConstraint` in `proxmox-server.dto.ts`: gültig nur, wenn `new URL(wert)` ohne Fehler parst, `protocol === 'https:'`, `hostname` nicht leer und `username`/`password` leer sind; Meldung deutsch in der ASCII-Schreibweise der übrigen API-Meldungen, z. B. „Nur https-Adressen ohne Zugangsdaten sind erlaubt.“), `category` (`@IsIn([...MODULE_CATEGORIES])` aus `@tessera/shared`). `UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto)` aus `@nestjs/mapped-types` (Muster `ldap-config.dto.ts`). Spec `dto/custom-module.dto.spec.ts` mit `plainToInstance` + `validate` deckt die Fälle aus `` ab. 4. Dienst `apps/api/src/custom-modules/custom-modules.service.ts` (D-03, D-04): `@Injectable` mit `PrismaService`; Methoden `list(tenantId)`, `getOne(tenantId, id)`, `create(tenantId, dto)`, `update(tenantId, id, dto)`, `remove(tenantId, id)`. JEDE Methode beginnt mit genau der Zuweisung `const tenantPrisma = forTenant(this.prisma, tenantId);` — `rls-access-inventory.spec.ts` erkennt nur diese Form, ein anderer Name oder ein Aufruf ohne Zuweisung macht die Spec rot. `list` filtert zusätzlich explizit `where: { tenantId }` und sortiert `orderBy: { name: 'asc' }`. `getOne`/`update`/`remove` lesen per `findUnique({ where: { id } })` und werfen `NotFoundException`, wenn die Zeile fehlt oder `row.tenantId !== tenantId` (zweites Netz, weil der RLS-Schalter heute aus ist — Muster DashboardImage). Antworten wählen per `select` genau `id, name, url, category, createdAt, updatedAt`; wird dafür eine Konstante genutzt, muss sie in derselben Datei als Objektliteral stehen (die Inventar-Spec löst nur solche Konstanten auf). `remove` liefert `{ deleted: true }`. Spec `custom-modules.service.spec.ts` nach Muster `proxmox.service.spec.ts` (`vi.mock('../prisma/prisma-tenant.extension', ...)` mit durchreichendem `forTenant`, Fake-Prisma mit Map). 5. Controller `apps/api/src/custom-modules/custom-modules.controller.ts` (D-04, D-10): `@Controller('custom-modules')`, `requireTenantId(req)` wie im Proxmox-Controller. Deklarationsreihenfolge verbindlich: `list` (`@Get()`), dann `getOne` (`@Get(':id')`), dann `create` (`@Post()`), `update` (`@Patch(':id')`), `remove` (`@Delete(':id')`); die drei schreibenden mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. Kopfkommentar: jede künftige statische GET-Route MUSS über `getOne` stehen (sonst fängt `:id` sie ab). Kein `@UseModule` — eigene Module hängen an keiner Modul-Aktivierung, sichtbar für alle (D-01). Spec `custom-modules.controller.spec.ts` nach Muster `bug-reports.controller.spec.ts`/`tenders.controller.spec.ts`: Rollen-Metadaten per `Reflect.getMetadata(ROLES_KEY, ...)`, Reihenfolge per `Object.getOwnPropertyNames(CustomModulesController.prototype)`, tenantId-Weitergabe, ForbiddenException ohne Mandant. 6. `apps/api/src/custom-modules/custom-modules.module.ts` (Controller + Dienst; PrismaModule ist global — prüfen, wie ProxmoxModule an PrismaService kommt, und genauso verfahren) und Aufnahme von `CustomModulesModule` in `imports` von `apps/api/src/app.module.ts`. 7. Zugriffsklassifikation (D-03) in `docs/mandantentrennung-zugriffsklassifikation.md`, alle Zahlen NACHGEMESSEN, nicht abgeschrieben: (a) in der Bestandsaufnahme-Tabelle (Kopf `| Datei | Modell | Klasse | Stand | Begründung |`) hinter den Proxmox-Zeilen die Zeile `| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** ... |` mit Begründung (Admin-verwaltete Seitenleisten-Einträge, tenantId-Spalte, tenant_isolation_policy ohne Benutzerdimension, Migration 20260929120000, keine system_read_policy, je Methode ein forTenant-Klient, Besitzprüfung row.tenantId -> 404). (b) In der Übersicht je Bereich eine Zeile `custom-modules` vor der Summenzeile. Gemessen wird mit der Gate-Schleife über `for d in apps/api/src/*/` mit den drei Greps `this\.prisma\.[a-zA-Z]*`, `tenantPrisma\.[a-zA-Z]*\.` und `systemPrisma\.[a-zA-Z]*\.` (nur .ts ohne spec). Beim Planen gemessen: Summe vorher 61/217/6, die Tabelle nennt aber 61/216/6 — die Zeile `user` nennt 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer). Diese Drift in der Zeile `user` und in der Summenzeile mit „Nachgemessen quick-260929-9wc“ korrigieren, dann die neue Summe eintragen. (c) Klassen-Verteilung: Überschrift und Tabelle nennen 77 Paare/40 muss-mandantengebunden, die Bestandsaufnahme hat beim Planen aber schon 78 Zeilen/41 muss (gezählt mit `grep -cE '^\| apps/api/src/'`); nach dem neuen Eintrag nachzählen (erwartet 79/42), Überschrift, Tabelle und einen Nachtrag-Absatz „quick-260929-9wc“ entsprechend fortschreiben (Drift benennen, dann +1). 8. Web-Client `apps/web/src/lib/custom-modules-api.ts` nach Muster `favorites-api.ts`/`proxmox-api.ts` (`NEXT_PUBLIC_API_URL`, `credentials: 'include'`): Typ `CustomModule` (`id, name, url, category, createdAt, updatedAt`), `listCustomModules()`, `getCustomModule(id)` (liefert `null` bei 404), `createCustomModule(input)`, `updateCustomModule(id, input)`, `deleteCustomModule(id)` — Fehler werfen mit Status und Servermeldung. Dazu die reine Funktion `checkCustomModuleUrl(value): 'ok' | 'notHttps' | 'credentials'`, die für die https-Prüfung `isHttpsUrl` aus `xframe-config.ts` nutzt (EINE https-Regel im Web) und Zugangsdaten per URL-Parser erkennt. Test `custom-modules-api.test.ts`. 9. Seitenleiste `apps/web/src/components/layout/sidebar.tsx` (D-05): im bestehenden Abruf-Effekt (derselbe Auslöser `sidebarRefreshKey`) zusätzlich `listCustomModules()` laden, Fehler still wie beim Modulabruf (leere Liste). Einträge vereinheitlichen (z. B. interner Typ mit `key`, `name`, `category`, `href`, `tileSlug`): Module behalten `href = /modules//` und ihre Aktiv-Regel, eigene Module bekommen `href = /modules/custom/` und das allgemeine Kachelsymbol (`ModuleTile` mit einer Kennung ohne eigenes Symbol, z. B. `custom`). Gruppierung, Suche, eingeklappte Kachelliste und der Leer-Zustand arbeiten auf der vereinigten Liste; innerhalb einer Kategorie stehen eingebaute Module vor eigenen. Für den Kopfzeilen-Titel die vereinigte Liste in `useNavStore` veröffentlichen, eigene Module mit `slug` = ihre id (`resolvePageTitle` findet das Pfadsegment dann ohne Änderung) — Test in neuer Datei `apps/web/src/lib/stores/nav-store.test.ts`. In `sidebar.test.tsx` `@/lib/custom-modules-api` per `vi.mock` ersetzen (Standard: leere Liste), damit die bestehenden Zähltests auf `fetch` unverändert gelten; neue Tests für die Fälle aus ``. 10. Rahmen-Seite (D-06): `apps/web/src/app/(portal)/modules/custom/[id]/page.tsx` als Server-Komponente, die `params` (Promise, Muster `[moduleSlug]/page.tsx`) auflöst und `` rendert — ohne ModuleAccessGate, weil eigene Module für alle sichtbar sind (D-01). `apps/web/src/components/modules/custom-module-view.tsx` (Client): lädt per `getCustomModule(id)`; Ladezustand, Nicht-gefunden-Text, sonst eine schmale Leiste (Name, kurzer Hinweis dass manche Seiten das Einbetten verbieten, rechts der Link „In neuem Tab öffnen“ als echter `` mit `target="_blank"` und `rel="noopener noreferrer"`, als Knopf gestaltet und immer sichtbar) und darunter das iframe, das die restliche Höhe füllt (Behälter z. B. `flex flex-col` mit Höhe `calc(100vh - var(--header-height) - 1.5rem)`, iframe `flex-1 w-full rounded-lg border-0 bg-background`). iframe-Attribute wie `frameAttrs` im XFrame-Widget: `src`, `title` = Name, `sandbox={XFRAME_SANDBOX}` (importiert aus `xframe-config.ts`, NICHT kopieren), `allow=""`, `referrerPolicy="no-referrer"`. iframe und Link nur, wenn `checkCustomModuleUrl(url) === 'ok'`, sonst Hinweistext. Test `custom-module-view.test.tsx` mit gemocktem `getCustomModule`. 11. Texte (D-08) im neuen Namensraum `customModules` in `de.json` und `en.json`: mindestens `openInNewTab` („In neuem Tab öffnen“ / „Open in new tab“), `embedHint` (z. B. „Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.“), `notFound` („Dieses Modul gibt es nicht mehr.“), `invalidUrl`. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel oben behandeln. 12. Datenbank lokal migrieren und API neu bauen (D-11): Container-IP holen mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `docker compose up -d --build api` und warten, bis `curl -sf http://localhost:3001/health` antwortet. Kontrolle, dass keine Schemaabweichung zu CustomModule bleibt: `pnpm --filter @tessera/api exec prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` darf „CustomModule“ nicht enthalten (andere, schon vorher bestehende Abweichungen aus handgeschriebenem SQL sind nicht Gegenstand dieser Aufgabe). 13. Lokal committen (z. B. `feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite`), NICHT pushen (D-12). cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/custom-modules src/prisma && pnpm --filter @tessera/web exec vitest run src/components/layout/sidebar.test.tsx src/components/modules/custom-module-view.test.tsx src/lib/custom-modules-api.test.ts src/lib/stores/nav-store.test.ts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && grep -q 'CREATE POLICY tenant_isolation_policy ON "CustomModule"' apps/api/prisma/migrations/20260929120000_custom_module/migration.sql && grep -q '| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'XFRAME_SANDBOX' apps/web/src/components/modules/custom-module-view.tsx && J=$(mktemp) && curl -sf -c "$J" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && ID=$(curl -sf -b "$J" -H 'Content-Type: application/json' -d '{"name":"Tracer","url":"https://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).id') && curl -sf -b "$J" http://localhost:3001/custom-modules | grep -q "$ID" && curl -sf -b "$J" "http://localhost:3001/custom-modules/$ID" | grep -q 'example.com' && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" -H 'Content-Type: application/json' -d '{"name":"X","url":"http://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules)" = 400 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && curl -sf -b "$J" -X DELETE "http://localhost:3001/custom-modules/$ID" >/dev/null && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "http://localhost:3001/custom-modules/$ID")" = 404 Tabelle CustomModule mit Zeilenschutz ist lokal angelegt; die neu gebaute API nimmt einen https-Eintrag vom Admin an, liefert ihn in Liste und Einzelabruf, lehnt http mit 400 und Anonyme mit 401 ab, löscht ihn (danach 404); Seitenleiste und Rahmen-Seite sind komponentengetestet; RLS-Specs grün, Zugriffsklassifikation nachgemessen fortgeschrieben; lokal committet, nicht gepusht. Aufgabe 2: Verwaltungsseite „Eigene Module“ — Liste, Anlegen, Bearbeiten, Löschen apps/web/src/app/(portal)/admin/custom-modules/page.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx, apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx, apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Kopf mit dem next-intl-Mock), apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/lib/custom-modules-api.ts (aus Aufgabe 1) - Ohne Einträge: Leer-Zustand mit Überschrift, kurzer Erklärung und Knopf „Eigenes Modul anlegen“ - Mit Einträgen: Tabelle mit Name (Link auf /modules/custom/), Adresse, Kategorie als Anzeigename (useCategoryLabel), Aktionen Bearbeiten und Löschen - Anlegen: Formular mit Name, Adresse, Kategorie-Auswahl aus MODULE_CATEGORIES; http-Adresse oder Adresse mit Zugangsdaten zeigt die passende Meldung und ruft createCustomModule NICHT auf; leerer Name ebenso; gültige Eingabe ruft createCustomModule mit getrimmtem Namen, lädt die Liste neu und ruft bumpSidebarRefresh genau einmal - Bearbeiten: Formular ist mit den Werten vorbelegt, Speichern ruft updateCustomModule(id, ...) und bumpSidebarRefresh - Löschen: Bestätigungsdialog nennt den Namen; Bestätigen ruft deleteCustomModule(id), Liste neu, bumpSidebarRefresh; Abbrechen ruft nichts - Serverfehler beim Speichern bleibt im Dialog sichtbar, Dialog bleibt offen - Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text statt der Seite (nur Anzeige; durchgesetzt wird serverseitig) Tests zuerst (`custom-modules-page.test.tsx`, Muster `groups-page.test.tsx`: namensraumfähiger next-intl-Mock, `@/lib/custom-modules-api` und `@/lib/stores/marketplace-store` per `vi.mock`, Auth-Store mit Rolle ADMIN bzw. USER), dann bauen (D-07): 1. Seite `apps/web/src/app/(portal)/admin/custom-modules/page.tsx` (Client) im Aufbau von `admin/groups/page.tsx`: Rollen-Anzeigeprüfung ADMIN/SUPER_ADMIN (sonst `common.accessDenied`), Überschrift „Eigene Module“ mit Knopf „Eigenes Modul anlegen“ (`btn btn-primary`), darunter ein Satz Erklärung (externe Webseiten als Einträge in der Seitenleiste, alle Benutzer sehen sie), Fehlerzeile im Stil der Gruppenseite, Leer-Zustand bzw. Tabelle (`overflow-x-auto rounded-md border border-border`, Kopf `bg-muted/50`) mit Name (Link auf die Rahmen-Seite), Adresse (gekürzt mit `truncate` und `title`), Kategorie über `useCategoryLabel()`, Aktionen Bearbeiten/Löschen. Nach jedem erfolgreichen Anlegen, Ändern oder Löschen: Liste neu laden und `useMarketplaceStore.getState().bumpSidebarRefresh()` (bzw. über den Hook) aufrufen, damit die Seitenleiste ohne Neuladen nachzieht (D-05). 2. `components/CustomModuleFormModal.tsx` nach Muster `GroupFormModal.tsx` (gleicher Dialog-Rahmen, gleiche Knopfklassen): Felder Name (Pflicht, `maxLength` 100), Adresse (`type="url"`, `maxLength` 2048, Platzhaltertext `https://…`), Kategorie (`