Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
48 KiB
phase, plan, quick_id, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
| phase | plan | quick_id | type | wave | depends_on | autonomous | requirements | files_modified | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260929-9wc | 01 | 260929-9wc | execute | 1 | true |
|
|
|
|
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.moduleIdist ein Pflicht-Fremdschlüssel aufModule(onDelete: Cascade), eigene Module sind keineModule-Zeilen. Eine Einschränkung bräuchte eine neue Freigabetabelle oder einen Umbau vonModuleGrantsamtmodule-access.service.tsund 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 MusterProxmoxServer(tenantId-Spalte, Regel, prisma-tenant-Erweiterung); RLS-Inventar-Test unddocs/mandantentrennung-zugriffsklassifikation.mdfortschreiben. - 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), danachdocker 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 inmoduleCategoriesvonde.json/en.jsonstehen und überuseCategoryLabel()aufgelöst werden. Neu: diese Liste einmal alsMODULE_CATEGORIESinpackages/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_policyOHNE Benutzerdimension, weil Verwaltungsdaten des Mandanten). KEINEsystem_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 ausreq.tenantId) undproxmox.service.ts(je Methodeconst tenantPrisma = forTenant(this.prisma, tenantId);). Globale Wächter JwtAuthGuard/TenantGuard/RolesGuard stehen inapp.module.ts; ValidationPipe mitwhitelist: true, transform: trueinmain.ts. - Rahmen-Vorbild:
apps/web/src/components/dashboard/widgets/xframe-config.ts(XFRAME_SANDBOX, Begründung im Dateikopf;isHttpsUrlauspicture-frame-config.ts) undxframe-widget.tsx(frameAttrsmitallow: '',referrerPolicy: 'no-referrer';NewTabLinkals echter Link). - Seitenleiste:
apps/web/src/components/layout/sidebar.tsxlädt/modules/active, gruppiert nach Kategorie, Auffrischung überuseMarketplaceStoresidebarRefreshKey/bumpSidebarRefresh; sie veröffentlicht die Liste inuseNavStore, aus derresolvePageTitleden Kopfzeilen-Titel über Pfadsegment ==slugfindet. - Routen: Der statische Ordner
modules/custom/[id]hat im App Router Vorrang vormodules/[category]/[moduleSlug]— kein Konflikt.
Verbindliche Regeln für alle Aufgaben
de.jsonmit echten Umlauten.umlaut-guard.spec.tsmeldet 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 inUMLAUT_ALLOWLISTinapps/web/src/messages/umlaut-dictionary.ts. Jeder neue Schlüssel inde.jsonUNDen.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.mdund.planning/HANDOFF.json(Sitzungsübergabe) nicht wiederherstellen. - Commits nur lokal. Kein
git push, auch nicht am Ende (D-12).
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
-
Gemeinsame Kategorienliste (D-01): in
packages/shared/src/index.tsMODULE_CATEGORIESalsas const-Liste der fünf Kennungendomain-tools,security-tools,fleet,infrastructure,procurementplusexport type ModuleCategory, mit kurzem Kommentar, dass die Liste den Seed-Kategorien der Module und den SchlüsselnmoduleCategoriesin den Übersetzungen entspricht. Neue Specapps/web/src/messages/module-categories.spec.tsprüft den Gleichlauf mitde.jsonunden.json. -
Datenbank (D-03): in
apps/api/prisma/schema.prismahinterProxmoxServerStatusdas ModellCustomModulemitid 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). Migrationapps/api/prisma/migrations/20260929120000_custom_module/migration.sqlvon 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 SECURITYundCREATE POLICY tenant_isolation_policy ON "CustomModule" USING ("tenantId" = current_tenant_id());. Danachpnpm --filter @tessera/api exec prisma generate. -
DTO
apps/api/src/custom-modules/dto/custom-module.dto.ts(D-04):CreateCustomModuleDtomitname(@Transformtrimmt Zeichenketten,@IsString,@IsNotEmpty,@MaxLength(100)),url(@IsString,@MaxLength(2048), eigene@ValidatorConstraintnach MusterPmgOhneTokenConstraintinproxmox-server.dto.ts: gültig nur, wennnew URL(wert)ohne Fehler parst,protocol === 'https:',hostnamenicht leer undusername/passwordleer 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(Musterldap-config.dto.ts). Specdto/custom-module.dto.spec.tsmitplainToInstance+validatedeckt die Fälle aus<behavior>ab. -
Dienst
apps/api/src/custom-modules/custom-modules.service.ts(D-03, D-04):@InjectablemitPrismaService; Methodenlist(tenantId),getOne(tenantId, id),create(tenantId, dto),update(tenantId, id, dto),remove(tenantId, id). JEDE Methode beginnt mit genau der Zuweisungconst tenantPrisma = forTenant(this.prisma, tenantId);—rls-access-inventory.spec.tserkennt nur diese Form, ein anderer Name oder ein Aufruf ohne Zuweisung macht die Spec rot.listfiltert zusätzlich explizitwhere: { tenantId }und sortiertorderBy: { name: 'asc' }.getOne/update/removelesen perfindUnique({ where: { id } })und werfenNotFoundException, wenn die Zeile fehlt oderrow.tenantId !== tenantId(zweites Netz, weil der RLS-Schalter heute aus ist — Muster DashboardImage). Antworten wählen perselectgenauid, 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).removeliefert{ deleted: true }. Speccustom-modules.service.spec.tsnach Musterproxmox.service.spec.ts(vi.mock('../prisma/prisma-tenant.extension', ...)mit durchreichendemforTenant, Fake-Prisma mit Map). -
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()), danngetOne(@Get(':id')), danncreate(@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 übergetOnestehen (sonst fängt:idsie ab). Kein@UseModule— eigene Module hängen an keiner Modul-Aktivierung, sichtbar für alle (D-01). Speccustom-modules.controller.spec.tsnach Musterbug-reports.controller.spec.ts/tenders.controller.spec.ts: Rollen-Metadaten perReflect.getMetadata(ROLES_KEY, ...), Reihenfolge perObject.getOwnPropertyNames(CustomModulesController.prototype), tenantId-Weitergabe, ForbiddenException ohne Mandant. -
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 vonCustomModulesModuleinimportsvonapps/api/src/app.module.ts. -
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 Zeilecustom-modulesvor der Summenzeile. Gemessen wird mit der Gate-Schleife überfor d in apps/api/src/*/mit den drei Grepsthis\.prisma\.[a-zA-Z]*,tenantPrisma\.[a-zA-Z]*\.undsystemPrisma\.[a-zA-Z]*\.(nur .ts ohne spec). Beim Planen gemessen: Summe vorher 61/217/6, die Tabelle nennt aber 61/216/6 — die Zeileusernennt 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer). Diese Drift in der Zeileuserund 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 mitgrep -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). -
Web-Client
apps/web/src/lib/custom-modules-api.tsnach Musterfavorites-api.ts/proxmox-api.ts(NEXT_PUBLIC_API_URL,credentials: 'include'): TypCustomModule(id, name, url, category, createdAt, updatedAt),listCustomModules(),getCustomModule(id)(liefertnullbei 404),createCustomModule(input),updateCustomModule(id, input),deleteCustomModule(id)— Fehler werfen mit Status und Servermeldung. Dazu die reine FunktioncheckCustomModuleUrl(value): 'ok' | 'notHttps' | 'credentials', die für die https-PrüfungisHttpsUrlausxframe-config.tsnutzt (EINE https-Regel im Web) und Zugangsdaten per URL-Parser erkennt. Testcustom-modules-api.test.ts. -
Seitenleiste
apps/web/src/components/layout/sidebar.tsx(D-05): im bestehenden Abruf-Effekt (derselbe AuslösersidebarRefreshKey) zusätzlichlistCustomModules()laden, Fehler still wie beim Modulabruf (leere Liste). Einträge vereinheitlichen (z. B. interner Typ mitkey,name,category,href,tileSlug): Module behaltenhref = /modules/<kategorie>/<slug>und ihre Aktiv-Regel, eigene Module bekommenhref = /modules/custom/<id>und das allgemeine Kachelsymbol (ModuleTilemit 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 inuseNavStoreveröffentlichen, eigene Module mitslug= ihre id (resolvePageTitlefindet das Pfadsegment dann ohne Änderung) — Test in neuer Dateiapps/web/src/lib/stores/nav-store.test.ts. Insidebar.test.tsx@/lib/custom-modules-apipervi.mockersetzen (Standard: leere Liste), damit die bestehenden Zähltests auffetchunverändert gelten; neue Tests für die Fälle aus<behavior>. -
Rahmen-Seite (D-06):
apps/web/src/app/(portal)/modules/custom/[id]/page.tsxals Server-Komponente, dieparams(Promise, Muster[moduleSlug]/page.tsx) auflöst und<CustomModuleView id={id} />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 pergetCustomModule(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<a>mittarget="_blank"undrel="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-colmit Höhecalc(100vh - var(--header-height) - 1.5rem), iframeflex-1 w-full rounded-lg border-0 bg-background). iframe-Attribute wieframeAttrsim XFrame-Widget:src,title= Name,sandbox={XFRAME_SANDBOX}(importiert ausxframe-config.ts, NICHT kopieren),allow="",referrerPolicy="no-referrer". iframe und Link nur, wenncheckCustomModuleUrl(url) === 'ok', sonst Hinweistext. Testcustom-module-view.test.tsxmit gemocktemgetCustomModule. -
Texte (D-08) im neuen Namensraum
customModulesinde.jsonunden.json: mindestensopenInNewTab(„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. -
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, dannDATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy; danachdocker compose up -d --build apiund warten, biscurl -sf http://localhost:3001/healthantwortet. 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 --scriptdarf „CustomModule“ nicht enthalten (andere, schon vorher bestehende Abweichungen aus handgeschriebenem SQL sind nicht Gegenstand dieser Aufgabe). -
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.
-
Seite
apps/web/src/app/(portal)/admin/custom-modules/page.tsx(Client) im Aufbau vonadmin/groups/page.tsx: Rollen-Anzeigeprüfung ADMIN/SUPER_ADMIN (sonstcommon.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, Kopfbg-muted/50) mit Name (Link auf die Rahmen-Seite), Adresse (gekürzt mittruncateundtitle), Kategorie überuseCategoryLabel(), Aktionen Bearbeiten/Löschen. Nach jedem erfolgreichen Anlegen, Ändern oder Löschen: Liste neu laden unduseMarketplaceStore.getState().bumpSidebarRefresh()(bzw. über den Hook) aufrufen, damit die Seitenleiste ohne Neuladen nachzieht (D-05). -
components/CustomModuleFormModal.tsxnach MusterGroupFormModal.tsx(gleicher Dialog-Rahmen, gleiche Knopfklassen): Felder Name (Pflicht,maxLength100), Adresse (type="url",maxLength2048, Platzhaltertexthttps://…), Kategorie (<select>überMODULE_CATEGORIESaus@tessera/shared, beschriftet mituseCategoryLabel(), Vorgabe beim Anlegen:infrastructure). Vor dem SendencheckCustomModuleUrlaus Aufgabe 1 anwenden und je Ergebnis eine eigene übersetzte Meldung zeigen; Name wird getrimmt. Beim Bearbeiten nurupdateCustomModule, beim Anlegen nurcreateCustomModule. Serverfehler im Dialog anzeigen. -
components/DeleteCustomModuleDialog.tsxnach MusterDeleteGroupDialog.tsx: Rückfrage mit Namen, Bestätigen/Abbrechen. -
apps/web/src/components/admin/admin-sidebar.tsx: neuer Eintrag direkt hinter „Module“ mithref: '/admin/custom-modules',label: t('admin.customModules'),show: true, Symbol im Stil der übrigen 16-px-Strichsymbole (z. B. Fenster mit Pfeil nach außen oder Puzzleteil). Der Pfad beginnt NICHT mit/admin/modules, damit „Module“ nicht mitmarkiert wird. -
Texte (D-08) in
de.jsonunden.json:header.admin.customModules(„Eigene Module“ / „Custom modules“) und Namensraumadmin.customModulesmit Titel, Erklärung, Anlegen, Bearbeiten, Löschen, Feldbeschriftungen (Name, Adresse, Kategorie), Aktionen-Spalte, Leer-Zustand (Überschrift + Satz), Löschrückfrage mit{name}(z. B. „Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.“), MeldungennameRequired,urlNotHttps(„Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“),urlCredentials(„Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“), Speichern-Fehler. Sie-Form. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel behandeln. -
Lokal committen (z. B.
feat(web): Verwaltung „Eigene Module“), NICHT pushen (D-12). cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/custom-modules" src/components/layout/sidebar.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "/admin/custom-modules" apps/web/src/components/admin/admin-sidebar.tsx && grep -q "bumpSidebarRefresh" "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" && grep -q "MODULE_CATEGORIES" "apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); if (!m.header.admin.customModules) throw new Error(f + ": header.admin.customModules fehlt"); for (const k of ["title","create","urlNotHttps","urlCredentials","nameRequired"]) if (!(k in m.admin.customModules)) throw new Error(f + ": admin.customModules." + k + " fehlt"); for (const k of ["openInNewTab","embedHint","notFound"]) if (!(k in m.customModules)) throw new Error(f + ": customModules." + k + " fehlt"); }' Unter Verwaltung > Eigene Module listet die Seite alle Einträge des Mandanten; Anlegen, Bearbeiten und Löschen funktionieren mit Prüfung der Adresse im Formular und ziehen die Seitenleiste sofort nach; Texte de/en vollständig; Tests grün; lokal committet, nicht gepusht.
-
Alle Tore laufen lassen und die gemessenen Zahlen im SUMMARY festhalten: vollständige Web- und API-Testläufe,
pnpm turbo run type-check lint, Biome-Warnungen Web höchstens 55 und API höchstens 82. -
Stack neu bauen (D-11): Migration ist aus Aufgabe 1 bereits angewendet (zur Sicherheit erneut
prisma migrate deployüber die Container-IP, muss „No pending migrations“ melden), danndocker compose up -d --build web api; warten, bishttp://localhost:3001/healthundhttp://localhost:3000/loginantworten. -
Lokal committen (z. B.
docs(changelog): eigene Module unter Unveröffentlicht), NICHT pushen (D-12). Zum Schluss prüfen, dass HEAD auf keinem entfernten Zweig liegt. -
Browser-Prüfung (D-11) nach der Liste in
<verification>— Playwright MCP, echte Navigation, dunkel über den Theme-Knopf. Ist Playwright MCP im Ausführungskontext nicht verfügbar, die Prüfung im SUMMARY als „an den Orchestrator übergeben“ vermerken; der Orchestrator führt sie dann durch. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 55 && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${A:-0}" -le 82 && sed -n '/^## Unveröffentlicht/,/^## 1.5.2/p' CHANGELOG.md | grep -q "Eigene Module" && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/login)" = 200 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && test -z "$(git branch -r --contains HEAD)" Browser-Prüfung im Dunkelmodus nach den Schritten 1-9 in <verification> (Playwright MCP, lokaler Stack nachdocker compose up -d --build web api). CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“; alle Test-, Typ- und Lint-Tore grün, Biome-Grundlinie gehalten; web und api laufen neu gebaut; Browser-Prüfung im Dunkelmodus durchgeführt (oder ausdrücklich an den Orchestrator übergeben); alle Commits lokal, nichts gepusht.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser -> API /custom-modules |
Nicht vertrauenswürdige Eingaben (Name, Adresse, Kategorie, id) und Rollenanspruch aus der Sitzung |
| Admin-Eingabe -> alle Benutzer des Mandanten | Eine vom Admin gespeicherte Adresse wird jedem Benutzer als Rahmen und Link ausgeliefert |
| Tessera-Seite -> eingebettete Fremdseite | Fremder Inhalt läuft im Rahmen innerhalb des Tessera-Tabs |
| API -> PostgreSQL | Mandantentrennung über tenantId, forTenant und tenant_isolation_policy |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-9WC-01 | Elevation of Privilege | CustomModulesController POST/PATCH/DELETE | high | mitigate | @Roles(Role.ADMIN, Role.SUPER_ADMIN) an den drei schreibenden Methoden, globaler RolesGuard; Controller-Spec prüft die Metadaten; GET-Routen bewusst ohne Rolle (D-04) |
| T-9WC-02 | Information Disclosure | CustomModulesService, Tabelle CustomModule | high | mitigate | tenantId ausschließlich aus req.tenantId; je Methode const tenantPrisma = forTenant(this.prisma, tenantId); list filtert zusätzlich where: { tenantId }; getOne/update/remove prüfen row.tenantId !== tenantId -> 404; Migration mit ENABLE/FORCE RLS und tenant_isolation_policy; rls-coverage/rls-access-inventory grün |
| T-9WC-03 | Tampering | Adresse (DTO + Web-Rendering) | high | mitigate | API: eigene Constraint über den URL-Parser, nur https:, Hostname nötig, max 2048; Web: iframe und Link nur bei checkCustomModuleUrl(url) === 'ok' — javascript:, data: und http: werden nie gerendert, auch nicht bei manipulierter Datenbankzeile |
| T-9WC-04 | Spoofing | Eingebettete Fremdseite | medium | mitigate | sandbox={XFRAME_SANDBOX} (ohne Navigation des obersten Fensters und ohne allow-modals, Begründung in xframe-config.ts), allow=""; Test prüft den exakten Sandbox-Wert |
| T-9WC-05 | Information Disclosure | Referrer an Fremdseite | low | mitigate | referrerPolicy="no-referrer" am iframe, rel="noopener noreferrer" am Link „In neuem Tab öffnen“ |
| T-9WC-06 | Information Disclosure | Zugangsdaten in der Adresse | medium | mitigate | API und Formular lehnen Adressen mit Benutzername/Kennwort ab — sonst sähe jeder Benutzer die Zugangsdaten in der Adresse |
| T-9WC-07 | Denial of Service | Name/Adresse-Felder | low | mitigate | @MaxLength(100) Name, @MaxLength(2048) Adresse, Kategorie per @IsIn auf fünf Werte begrenzt; ValidationPipe whitelist: true verwirft Zusatzfelder (z. B. untergeschobenes tenantId) |
| T-9WC-08 | Spoofing | Admin bindet eine täuschend echte Fremdseite ein | low | accept | Der Admin ist vertrauenswürdig (ASVS L1); Einträge sind nur für Admins änderbar, der Name steht sichtbar in Leiste und Kopfzeile |
| T-9WC-SC | Tampering | npm/pip/cargo installs | high | accept | Dieser Plan installiert keine Pakete; alle genutzten Bibliotheken (class-validator, @nestjs/mapped-types, Prisma) sind bereits im Lockfile |
| </threat_model> |
Browser-Prüfung (D-11) — Playwright MCP gegen web :3000, Anmeldung admin / admin123, IMMER echte
Navigation (browser_navigate) und gerenderten Inhalt auslesen, nie per fetch() aus der Seite
messen. Zuerst über den Theme-Knopf der Kopfzeile auf dunkel schalten (nicht per classList):
- Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste, „Module“ ist dabei nicht markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“.
- Anlegen mit Name „Beispielseite“, Adresse
http://example.com-> Meldung, nichts gespeichert; dannhttps://user:pw@example.com-> Meldung; dannhttps://example.com, Kategorie „Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“ unter „Infrastruktur“ OHNE Neuladen. - Zweiter Eintrag „GitHub“,
https://github.com, Kategorie „Sicherheit“ -> erscheint unter „Sicherheit“. - Klick auf „Beispielseite“:
/modules/custom/<id>, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke am Eintrag, der Rahmen füllt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“ sichtbar; im Accessibility-Snapshot/DOM trägt das iframe den Sandbox-Wert ausXFRAME_SANDBOXundreferrerpolicy="no-referrer". Der Link öffnet einen neuen Tab mit example.com. - Klick auf „GitHub“: der Rahmen zeigt die Einbettungssperre des Browsers, der Knopf „In neuem Tab öffnen“ ist trotzdem sichtbar und funktioniert.
- Seitenleiste eingeklappt: beide Einträge als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den Eintrag.
- Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Löschen mit
Rückfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; die alte Adresse
/modules/custom/<id>zeigt „Dieses Modul gibt es nicht mehr.“ - Sprache auf Englisch: keine rohen Übersetzungsschlüssel auf Verwaltungsseite und Rahmen-Seite.
- Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Einträgen und Rahmen-Seite ablegen; danach die Testeinträge löschen, damit die lokale Datenbank sauber bleibt.
<success_criteria>
- Admins verwalten eigene Module (Name, https-Adresse, Kategorie) unter Verwaltung > Eigene Module; alle Benutzer sehen sie unter der Kategorie in der Seitenleiste (D-01, D-05, D-07).
- Die Rahmen-Seite bettet nur https-Adressen ein, mit dem XFrame-Sandbox-Wert und ohne Referrer, und zeigt immer „In neuem Tab öffnen“ (D-06).
- API: GET für jeden Angemeldeten, Schreiben nur Admin, http und Zugangsdaten in der Adresse werden
abgewiesen;
liststeht vorgetOne(D-04, D-10). - Tabelle CustomModule mit Zeilenschutz; Zugriffsklassifikation nachgemessen fortgeschrieben (inkl.
der beim Planen gefundenen Drift in
userund der Klassen-Verteilung); RLS-Specs grün (D-03). - Texte de/en in Sie-Form, CHANGELOG ergänzt (D-08, D-09); alle Tore grün, Biome-Grundlinie gehalten.
- Browser-Prüfung im Dunkelmodus bestanden (D-11); alle Commits nur lokal (D-12).
- Gruppen-Einschränkung bewusst NICHT gebaut, im SUMMARY als zurückgestellt begründet (D-02). </success_criteria>