Files
tessera-ctl/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-PLAN.md
T
schalli 8686b1a673
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m11s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m4s
docs(quick-260921-pi9): Akte - Bilderrahmen-Widget gebaut, zehnpunktiger Browser-Rundgang bestanden
Plan, Zusammenfassung (mit Rot-Nachweis, curl-Rundgang, abgehakter
Pruefliste und den drei im Rundgang gefundenen Befunden) sowie die Zeile in
der Quick-Tabelle von STATE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:22:32 +02:00

47 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260921-pi9 01 execute 1
true
QUICK-260921-PI9
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
apps/api/src/dashboard/dashboard-image-rules.ts
apps/api/src/dashboard/dashboard-image-rules.spec.ts
apps/api/src/dashboard/dashboard-images.service.ts
apps/api/src/dashboard/dashboard-images.service.spec.ts
apps/api/src/dashboard/dashboard-images.controller.ts
apps/api/src/dashboard/dashboard-images.controller.spec.ts
apps/api/src/dashboard/dashboard.module.ts
apps/api/src/dashboard/dto/create-widget.dto.ts
docs/mandantentrennung-zugriffsklassifikation.md
apps/web/src/lib/dashboard-images-api.ts
apps/web/src/lib/dashboard-images-api.test.ts
apps/web/src/components/dashboard/widgets/picture-frame-config.ts
apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
apps/web/src/components/settings/picture-frame-config-form.tsx
apps/web/src/components/settings/picture-frame-config-form.test.tsx
apps/web/src/components/settings/widget-settings-panel.tsx
apps/web/src/components/dashboard/widget-registry.tsx
apps/web/src/components/dashboard/widget-registry.test.tsx
apps/web/src/components/dashboard/widget-catalog-modal.tsx
apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/app/(portal)/page.test.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
CHANGELOG.md
tokens raw_tokens tasks confidence
260000 260000 3 low
truths artifacts key_links
Im Widget-Katalog gibt es „Bilderrahmen“ (en „Picture frame“); eine frisch platzierte Kachel zeigt den Hinweis „Noch keine Bilder — über die Einstellungen hinzufügen“ im Stil der anderen leeren Widgets.
Unter Einstellungen → Dashboard → Bilderrahmen kann der Benutzer Bilder hochladen (PNG/JPEG/GIF/WebP, je Datei höchstens 5 MiB, je Benutzer höchstens 30) ODER eine https-Webadresse eintragen; http-, data- oder javascript-Adressen werden mit deutscher Meldung abgewiesen, eine umbenannte Nicht-Bild-Datei ebenso (Erkennung an den Magic Bytes, nicht am Dateinamen oder am gemeldeten MIME-Typ).
Jeder Eintrag hat Vorschaubild, Bildunterschrift, Entfernen und Pfeile Nach oben/Nach unten; das Entfernen eines hochgeladenen Bildes löscht es auch auf dem Server (best effort); ein Eintrag, dessen Bild nicht mehr existiert, wird als „Bild nicht verfügbar“ angezeigt und in der Kachel übersprungen.
Die Kachel zeigt die Bilder gemäß Einstellung ganz sichtbar (`object-contain`) oder formatfüllend (`object-cover`), wechselt im eingestellten Intervall (5–3600 s, 0 = kein Wechsel, Voreinstellung 30 s) in Reihenfolge oder zufällig (Zufall wählt bei mehr als einem Bild nie das aktuelle erneut), zeigt die Bildunterschrift als Streifen am unteren Rand; Fremdbilder lädt ausschließlich der Browser (`<img referrerPolicy="no-referrer">`), der Server ruft nie eine Webadresse ab.
Klick auf das Bild (nur außerhalb des Bearbeitungsmodus) öffnet eine Großansicht mit Bildunterschrift; Escape, Klick auf den Hintergrund oder der Schließen-Knopf schließen sie, der Fokus kehrt zum Bild zurück, der Bildwechsel pausiert solange. Im Bearbeitungsmodus bleibt die ganze Karte der Ziehgriff, ein Klick öffnet nichts.
Hochgeladene Bilder gehören dem hochladenden Benutzer: `GET/DELETE /dashboard/images/:id` liefern für eine fremde Kennung (anderer Benutzer ODER anderer Mandant) 404, nie 403; die Auslieferung trägt `Content-Type` aus dem gespeicherten, per Magic Bytes bestimmten Typ, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`.
Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1170 (heute 1148), Web-Tests mindestens 560 (heute 531), RLS-Wächter 30/30; keine neue `any`, `as unknown as` in apps/api/src bleibt 27, `noNonNullAssertion` bleibt 56.
apps/api/prisma/schema.prisma — Modell `DashboardImage` (id, userId, tenantId, originalName, mimeType, size, data Bytes, createdAt; @@index userId, tenantId)
apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql — CREATE TABLE + Indizes + ENABLE/FORCE ROW LEVEL SECURITY + `tenant_isolation_policy` mit Benutzerdimension
apps/api/src/dashboard/dashboard-image-rules.ts — reine Regeln: `detectImageMime(buffer)`, `DASHBOARD_IMAGE_MAX_BYTES`, `DASHBOARD_IMAGE_MAX_COUNT`
apps/api/src/dashboard/dashboard-images.service.ts — `list`, `upload`, `getBytes`, `remove`, alle über `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`
apps/api/src/dashboard/dashboard-images.controller.ts — `@Controller('dashboard/images')`: `GET /`, `POST /` (FileInterceptor `image`), `GET /:id` (Binär), `DELETE /:id`
apps/web/src/components/dashboard/widgets/picture-frame-config.ts — Typen `PictureFrameEntry`/`PictureFrameConfig`, `resolvePictureFrameConfig`, `isHttpsUrl`, `pickNextIndex`, Intervall-Grenzen
apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx + picture-frame-lightbox.tsx — Kachel mit Wechsel, Bildunterschrift, Großansicht
apps/web/src/components/settings/picture-frame-config-form.tsx — Bildverwaltung im WidgetSettingsPanel
apps/web/src/lib/dashboard-images-api.ts — `fetchDashboardImages`, `uploadDashboardImage`, `deleteDashboardImage`, `dashboardImageSrc`
apps/web/src/messages/de.json + en.json — Namensraum `widgets.pictureFrame`
CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“
Datei-Eingabe im Einstellungsformular -> `uploadDashboardImage(file)` (FormData-Feld `image`) -> `POST /dashboard/images` -> `detectImageMime` + Zähler -> `DashboardImage`-Zeile -> Antwort `{ id, … }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })` -> `PATCH /dashboard/widgets/:id/config` (bestehend, flache Zusammenführung: `images` immer als GANZES Array senden)
Kachel: `resolvePictureFrameConfig(config)` -> sichtbare Einträge -> `<img src={kind === 'upload' ? dashboardImageSrc(imageId) : url}>` -> `/api-proxy/dashboard/images/:id` (Next-Rewrite aus next.config.ts, Cookies laufen mit) -> `GET /dashboard/images/:id` -> Besitzprüfung -> Bytes
Wächter: neues Modell mit `tenantId` -> `rls-coverage.spec.ts` verlangt ENABLE + POLICY in einer Migration; neue (Datei, Modell)-Fundstelle `dashboard-images.service.ts`/`dashboardImage` -> `rls-access-inventory.spec.ts` verlangt eine Zeile in docs/mandantentrennung-zugriffsklassifikation.md

Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“

Ein neues Dashboard-Widget „Bilderrahmen“ (Widget-Typ `picture-frame`, Übersetzungs-Namensraum `widgets.pictureFrame`): Bilder werden hochgeladen (in PostgreSQL als `bytea`, dem Benutzer gehörend, 5 MiB je Datei, 30 je Benutzer) ODER als https-Webadresse eingebunden (der Browser lädt sie direkt, der Server ruft nie etwas ab). Einstellungen: Bildausschnitt, Wechselintervall, Reihenfolge/Zufall, Bildunterschrift je Eintrag; Klick zeigt das Bild groß. Stil und Bedienmuster wie die bestehenden Widgets.

Purpose: erstes der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (siehe Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen. Output: API-Modell + Migration + Endpunkte mit Tests, Web-Widget + Einstellungsformular + Großansicht + Übersetzungen mit Tests, Changelog-Eintrag; alle Tore grün.

Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)

  1. Speicherung als bytea im neuen Prisma-Modell DashboardImage (kein Docker-Volume; Sicherung deckt es mit ab). Migration als SQL-Datei, angewendet mit prisma migrate deploy — nie db push.
  2. Grenzen: 5 MiB je Datei (limits.fileSize), 30 Bilder je Benutzer (Dienst zählt je Mandant+Benutzer). Erlaubt PNG/JPEG/GIF/WebP, entschieden über Magic Bytes; alles andere 400 mit deutscher Meldung.
  3. Endpunkte unter dashboard/images (angemeldet): GET (eigene Liste, nur Metadaten), POST (multipart-Feld image), GET :id (Binär mit den genannten Headern), DELETE :id. Besitz = gleicher Mandant UND gleicher Benutzer; fremde Kennung → 404. Statische Route vor :id.
  4. Widget-Konfiguration im bestehenden Config-JSON: images: Array<{ kind: 'upload', imageId, caption? } | { kind: 'url', url, caption? }>, fit: 'contain' | 'cover', intervalSeconds (0 = kein Wechsel, sonst 5–3600, Voreinstellung 30), order: 'sequence' | 'random'. Befund am Code: die API prüft Widget-Konfigurationen NICHT inhaltlich — UpdateWidgetConfigDto trägt nur @IsObject(), DashboardService.updateWidgetConfig führt flach zusammen ({ ...alt, ...neu }). Es gibt also keine serverseitige Stelle, die erweitert werden könnte; die https-Prüfung läuft deshalb web-seitig zweifach: im Formular (Eingabe abweisen) UND beim Rendern (resolvePictureFrameConfig lässt jede Nicht-https-Adresse weg). Ein manipulierter Config-Wert schadet damit nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
  5. Klick auf das Bild nur außerhalb des Bearbeitungsmodus → Großansicht (Escape / Hintergrund / Schließen-Knopf; Fokus-Handhabung wie widget-catalog-modal.tsx: Dialog bei Öffnen fokussieren, zusätzlich Fokus-Rückgabe an den Auslöser). Im Bearbeitungsmodus kein Knopf → die ganze Karte bleibt Ziehgriff (widget-wrapper.tsx).
  6. Wechsel per Timer; Zufall wählt bei >1 Bild nie das aktuelle; Timer beim Aushängen geräumt; pausiert bei offener Großansicht.
  7. Leerzustand: „Noch keine Bilder — über die Einstellungen hinzufügen“, Stil flex h-full items-center justify-center text-sm text-muted-foreground (wie PlaceholderWidget/Favoriten-empty).
  8. Bildverwaltung im WidgetSettingsPanel (Einstellungen → Dashboard, je Instanz aufklappbar) — das ist die „Einstellungen“-Stelle dieser App; einen Dialog je Widget gibt es nicht. Vorschaubilder über /api-proxy/dashboard/images/:id (Muster FavoriteIcon, favorites-widget.tsx).
  9. Texte Deutsch mit „Sie“, plus Englisch; keine kundenspezifischen Vorgaben.
  10. Katalogname „Bilderrahmen“ / „Picture frame“, Beschreibung kurz („Bilder hochladen oder verlinken, als Diashow“ / „Upload or link images as a slideshow“).

Ausgangsmessung (21.09.2026, 573d070)

Größe Wert
API-Tests 1148
Web-Tests 531
as unknown as in apps/api/src 27
as unknown as in apps/web/src 6
lint/style/noNonNullAssertion in apps/api/src 56
lint/suspicious/noExplicitAny in apps/api/src 13 (jede begründet)
RLS-Wächter (src/prisma) 30/30

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@/home/vicolab/projects/tessera-ctl/CLAUDE.md @/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.service.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.controller.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.controller.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.service.ts @/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts @/home/vicolab/projects/tessera-ctl/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql @/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.tsx @/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx

Hinweise für den Executor

  • Lokale Datenbank ohne Host-Port. Migration anwenden: IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1); DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy; danach pnpm --filter @tessera/api exec prisma generate. Läuft der Container nicht: docker compose up -d db. Nie auf den Testserver deployen, nie prisma db push.
  • Tore vor jedem Commit: pnpm type-check, pnpm lint, die betroffenen Vitest-Dateien; am Ende pnpm --filter @tessera/api test und pnpm --filter @tessera/web test vollständig.
  • Wächter: rls-coverage.spec.ts liest Schema und Migrationen (Regex ALTER TABLE "X" ENABLE ROW LEVEL SECURITY und CREATE POLICY \w+ ON "X"); rls-access-inventory.spec.ts verlangt forTenant( nur in der Zuweisungsform const X = forTenant(, jede select:-Angabe innerhalb eines Modellaufrufs, und je (Datei, Modell) eine Zeile in docs/mandantentrennung-zugriffsklassifikation.md (Tabelle „| Datei | Modell | Klasse | Stand | Begründung |“, Zeile ~660).
  • Prisma 6: Bytes ist Uint8Array, nicht Buffer. data: file.buffer beim Anlegen geht (Buffer ist eine Uint8Array-Unterklasse); bei der Auslieferung res.send(Buffer.from(row.data.buffer, row.data.byteOffset, row.data.byteLength)) — keine Zusicherung nötig.
  • multer: LIMIT_FILE_SIZE bildet Nest auf 413 mit englischer Meldung ab (Muster T-M97-03 in bug-reports.controller.ts) — die deutsche Meldung für „zu groß“ entsteht im Web-Klienten aus dem Status 413.
  • Kein any, keine neue as unknown as, kein !. Multipart-Datei als bestehender Typ UploadedFileLike (apps/api/src/auth/types/auth-user.ts), Aufrufer als AuthUser über @CurrentUser().
  • Commits: je Aufgabe genau ein Commit, Stil wie git log --oneline -15, Scope quick-260921-pi9, deutsche Betreffzeile. Die Akte/STATE-Commit macht der Orchestrator.
  • Schema-Tor (Prisma erkannt): der [BLOCKING]-Schritt „Migration anwenden + prisma generate“ steht in Aufgabe 1 VOR dem Dienstcode; ohne ihn wären Typprüfung und Tests falsch-grün.
Aufgabe 1: API — Modell, Migration, Regeln, Dienst, Controller (Ende-zu-Ende „Bild hochladen und wieder abrufen“) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/src/dashboard/dashboard-image-rules.ts, apps/api/src/dashboard/dashboard-image-rules.spec.ts, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, apps/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md - `detectImageMime`: PNG-Signatur (`89 50 4E 47 0D 0A 1A 0A`) → `image/png`; `FF D8 FF` → `image/jpeg`; `GIF87a`/`GIF89a` → `image/gif`; `RIFF????WEBP` (Bytes 0–3 `RIFF`, 8–11 `WEBP`) → `image/webp`; leerer Puffer, Textdatei, SVG-Text, PDF (`%PDF`) → `null`; ein Puffer, der mit `RIFF` beginnt, aber ohne `WEBP` an Stelle 8 → `null`. - Dienst `upload`: keine Datei → `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; `detectImageMime === null` → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.')` — auch wenn `file.mimetype` „image/png“ behauptet; Zähler `count({ where: { tenantId, userId } })` ≥ 30 → `BadRequestException('Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.')`; sonst `create` mit `mimeType` aus der Erkennung (NICHT aus `file.mimetype`), `originalName` auf 255 Zeichen gekürzt, `size = buffer.length`, Antwort nur Metadaten (`id, originalName, mimeType, size, createdAt`). - Dienst `list`: `findMany({ where: { tenantId, userId }, select: { id, originalName, mimeType, size, createdAt }, orderBy: { createdAt: 'asc' } })` — `data` wird nie mitgeladen. - Dienst `getBytes`/`remove`: `findUnique({ where: { id } })`; fehlt die Zeile ODER `row.userId !== userId` ODER `row.tenantId !== tenantId` → `NotFoundException` (nie Forbidden); `remove` löscht danach. - Jede Methode holt ihren Klienten in der Zuweisungsform `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` (Spec-Attrappe wie in dashboard.service.spec.ts: `forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId))`, ein vergessener Aufruf fällt im Test auf). - Controller: `GET /dashboard/images` → `list`; `POST` mit `FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } })` → `upload(user, file)`; `GET /dashboard/images/:id` setzt `Content-Type` = gespeicherter `mimeType`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline` (ohne Dateinamen — `originalName` gehört nie in einen Header), zusätzlich `Content-Security-Policy: default-src 'none'; sandbox` (Muster `getIcon`), dann `res.send(Buffer)`; `DELETE /:id` → `remove`, Antwort `{ id }`. Kein `@Roles`-Dekorator (alle angemeldeten Rollen, Muster bug-reports). Mandant/Benutzer ausschließlich aus `@CurrentUser()`. - `CreateWidgetDto`: `@IsIn([...])` enthält zusätzlich `'picture-frame'`. **Schritt A — Schema und Migration.** In `schema.prisma` neben `WidgetInstance` das Modell `DashboardImage` anlegen: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `originalName String`, `mimeType String`, `size Int`, `data Bytes`, `createdAt DateTime @default(now())`, `@@index([userId])`, `@@index([tenantId])` (keine Relation, wie `WidgetInstance`). Migration `20260921120000_dashboard_image/migration.sql` von Hand schreiben (Muster CREATE TABLE: `20260708090000_add_favorite_link`, Muster RLS: `20260909140000_rls_remaining_tenant_tables` Abschnitt FavoriteLink plus Benutzerdimension aus `20260911120000`): deutscher Kopfkommentar (Zweck, Grenzen, Besitz), `CREATE TABLE "DashboardImage" (… "data" BYTEA NOT NULL …)`, beide Indizes, `ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;`, `ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;`, `CREATE POLICY tenant_isolation_policy ON "DashboardImage" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Rechte für `tessera_app` kommen über `ALTER DEFAULT PRIVILEGES` aus `20260909130000_rls_app_role` automatisch — nichts zu tun, im Kopfkommentar erwähnen.
**Schritt B [BLOCKING] — Migration anwenden und Klient erzeugen** (Befehle aus den Executor-Hinweisen: `prisma migrate deploy` gegen die Container-IP, dann `prisma generate`). Erst danach gibt es `tenantPrisma.dashboardImage` im Typsystem. Prüfen: `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` muss grün sein (Test 1/2 sehen das neue Modell und die neue Policy).

**Schritt C — Regeln zuerst, rot.** `dashboard-image-rules.ts` mit `export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024`, `DASHBOARD_IMAGE_MAX_COUNT = 30`, `export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'`, `export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null`. Spec mit den Fällen aus `<behavior>` (mindestens 8), vor der Umsetzung rot.

**Schritt D — Dienst und Controller, rot dann grün.** `dashboard-images.service.ts` (`@Injectable() DashboardImagesService`, Konstruktor `private readonly prisma: PrismaService`) mit `list(userId, tenantId)`, `upload(user: AuthUser, file: UploadedFileLike | undefined)`, `getBytes(id, userId, tenantId)` → `{ mimeType, data }`, `remove(id, userId, tenantId)`. Dateikopf-Kommentar wie in dashboard.service.ts: warum die Besitzprüfung zusätzlich zur RLS-Regel nicht dekorativ ist (Schalter heute aus). Spec mit `makeFakePrisma`-Muster aus dashboard.service.spec.ts, mindestens 10 Fälle (Liste ohne `data`; Upload ohne Datei; PNG mit behauptetem `text/plain`-mimetype gelingt und speichert `image/png`; Textdatei mit behauptetem `image/png` scheitert; Zähler 30 blockt, 29 lässt durch; fremder Benutzer → 404; fremder Mandant → 404; eigenes Bild liefert Bytes; Löschen eigen/fremd; `forTenant` mit `(prisma, tenantId, userId)` aufgerufen). `dashboard-images.controller.ts` (`@Controller('dashboard/images')`, Reihenfolge `@Get()` → `@Post()` → `@Get(':id')` → `@Delete(':id')`, `@Res() res: Response` aus `express` wie favorites.controller.ts). Controller-Spec (Muster bug-reports.controller.spec.ts, mindestens 4 Fälle): Interceptor-Grenzen `fileSize === DASHBOARD_IMAGE_MAX_BYTES`, `files === 1` über die Nest-Metadaten oder den Aufruf; die vier Header der Binärantwort inklusive `Content-Disposition: inline` ohne Dateinamen; kein `@Roles`-Metadatum; Pfad `dashboard/images`. Beide in `dashboard.module.ts` registrieren (`controllers`, `providers`). `create-widget.dto.ts` um `'picture-frame'` erweitern.

**Schritt E — Wächter-Dokument.** In `docs/mandantentrennung-zugriffsklassifikation.md` in der (Datei, Modell)-Tabelle eine Zeile `| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | … |` mit Begründung (Bilder eines Benutzers, `tenantId`-Spalte, Benutzerdimension in der Policy seit 20260921120000, Besitzprüfung zusätzlich in `getBytes`/`remove`, Liste/Zähler mit explizitem `where: { tenantId, userId }`). Die Bereichstabelle (Zeile „| dashboard | 1 | 12 | 0 |“) um die neuen gebundenen Treffer erhöhen — die Zahl mit der dort genannten Schleife messen, nicht schätzen.

Commit: `feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck` (Wortlaut frei, Stil beachten).
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint && test "$(grep -rn 'as unknown as' apps/api/src --include=*.ts | wc -l)" -eq 27 && STAT=$(git show --stat --format= HEAD) && printf '%s' "$STAT" | grep -q 'migrations/20260921120000_dashboard_image/migration.sql' Migration lokal angewendet (`prisma migrate status` meldet keine ausstehende Migration), `prisma generate` gelaufen. `dashboard-image-rules.spec.ts` ≥ 8, `dashboard-images.service.spec.ts` ≥ 10, `dashboard-images.controller.spec.ts` ≥ 4 Fälle — alle grün, davon die Regel- und Diensttests nachweislich zuerst rot (Rot-Lauf im SUMMARY nennen). RLS-Wächter `src/prisma` weiterhin 30/30 inklusive der neuen Zeile im Klassifikationsdokument. Ein Rundgang mit `curl` gegen die laufende lokale API (Cookie aus einer Anmeldung): `POST` mit einer PNG-Datei liefert 201 mit Metadaten, `GET /dashboard/images` listet sie ohne `data`, `GET /dashboard/images/` liefert die Bytes mit den vier Headern, eine Textdatei als `.png` liefert 400 mit der deutschen Meldung, eine 6-MiB-Datei 413, eine erfundene Kennung 404. Zähler `as unknown as` = 27, keine neue `any`, kein `!`. Aufgabe 2: Web — Widget, Großansicht, Einstellungsformular, Katalog, Übersetzungen apps/web/src/lib/dashboard-images-api.ts, apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx, apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx, apps/web/src/components/settings/picture-frame-config-form.tsx, apps/web/src/components/settings/picture-frame-config-form.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json - `resolvePictureFrameConfig({})` → `{ images: [], fit: 'contain', intervalSeconds: 30, order: 'sequence' }`; `intervalSeconds` 3 → 5, 9999 → 3600, 0 → 0, `'abc'` → 30; `fit: 'x'` → `'contain'`; `order: 'x'` → `'sequence'`; Einträge ohne gültiges `kind`, Upload ohne `imageId`-String, URL mit `http://`, `javascript:`, `data:` oder ohne Parser-Erfolg (`new URL` wirft) werden weggelassen; `caption` nur übernommen, wenn String, auf 200 Zeichen gekürzt. - `isHttpsUrl('https://a.de/b.jpg')` true; `'http://…'`, `'HTTPS://'`-Schreibweise → true (Protokoll kleingeschrieben vergleichen); `'ftp://'`, `'javascript:alert(1)'`, `'nicht-url'` false. - `pickNextIndex(current, count, order, random)`: `count ≤ 1` → 0; `sequence` → `(current + 1) % count`; `random` → mit gestelltem `random` nie `current` (über 200 Ziehungen bei `count = 3` kommt `current` nie heraus). - Widget: leere Konfiguration → Text `pictureFrame.empty`; ein URL-Eintrag → `` mit `src` = URL, `referrerPolicy="no-referrer"`, Klasse `object-contain`, bei `fit: 'cover'` `object-cover`, `alt` = Bildunterschrift oder `''`; Bildunterschrift als Streifen am unteren Rand; Upload-Eintrag → `src="/api-proxy/dashboard/images/"`; mit `intervalSeconds: 5` und zwei Bildern zeigt `vi.advanceTimersByTime(5000)` das zweite, `unmount()` räumt den Timer (kein `setState` nach dem Aushängen, `vi.getTimerCount()` 0); `intervalSeconds: 0` wechselt nie; `onError` am `` nimmt den Eintrag aus dem Umlauf (bei zwei Einträgen bleibt nur der andere; sind alle kaputt: Text `pictureFrame.unavailable`); Klick auf das Bild bei `isEditMode: false` öffnet `role="dialog"` mit großem Bild und Unterschrift, Escape schließt, Klick auf den Hintergrund-Knopf schließt, Schließen-Knopf schließt, danach hat der Bild-Knopf wieder den Fokus; bei offener Großansicht läuft `advanceTimersByTime` ohne Bildwechsel; bei `isEditMode: true` gibt es keinen Knopf (kein `role="button"` im Widget) und kein Klick öffnet etwas. - Einstellungsformular: zeigt Auswahl Bildausschnitt (`contain`/`cover`), Intervall (Auswahl mit Werten 0/5/10/15/30/60/120/300/600/1800/3600), Reihenfolge (`sequence`/`random`) — jede Änderung ruft `onChange` mit dem einen Feld; die Liste zeigt je Eintrag Vorschau (`` mit `referrerPolicy="no-referrer"`, bei `onError` stattdessen Text `pictureFrame.unavailable`), Unterschrift-Feld (Entwurf, Übernahme bei Blur/Enter → `onChange({ images })` mit dem ganzen Array), Pfeile hoch/runter (oberster Eintrag ohne „hoch“, unterster ohne „runter“, wie Favoriten), Entfernen; Entfernen eines Upload-Eintrags ruft `deleteDashboardImage(imageId)` (Fehler verschluckt) UND `onChange` mit dem verkürzten Array; „Webadresse hinzufügen“ mit `http://` zeigt `role="alert"` `pictureFrame.urlInvalid` und ruft `onChange` nicht; mit https ruft `onChange({ images: [...alt, { kind: 'url', url }] })`; Datei wählen ruft `uploadDashboardImage(file)` und danach `onChange({ images: [...alt, { kind: 'upload', imageId: '' }] })`; wirft der Upload, erscheint dessen Meldung als `role="alert"` und `onChange` bleibt aus; bei 30 Einträgen ist „Bild hochladen“ deaktiviert mit Hinweis `pictureFrame.limitReached`. - `dashboard-images-api`: `uploadDashboardImage` sendet `POST ${API_URL}/dashboard/images` mit `credentials: 'include'` und einem `FormData`, dessen Feld `image` die Datei ist (kein `Content-Type`-Header von Hand); Status 413 → `Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.')`; Status 400 mit `{ message: string }` → `Error(message)`; sonst allgemeine Meldung; `dashboardImageSrc('a b')` → `/api-proxy/dashboard/images/a%20b`. **Reihenfolge: reine Helfer zuerst (rot → grün), dann Widget, dann Formular, zuletzt Verdrahtung.**
1. `picture-frame-config.ts`: Typen `PictureFrameEntry` (Vereinigung mit `kind`-Unterscheider, siehe Entscheidung 4), `PictureFrameConfig`, Konstanten `PICTURE_FRAME_INTERVAL_MIN = 5`, `PICTURE_FRAME_INTERVAL_MAX = 3600`, `PICTURE_FRAME_INTERVAL_DEFAULT = 30`, `PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]`, `PICTURE_FRAME_MAX_IMAGES = 30`, `PICTURE_FRAME_CAPTION_MAX = 200`; Funktionen `isHttpsUrl`, `resolvePictureFrameConfig`, `pickNextIndex`, `entryKey(entry, index)` (stabiler React-Schlüssel `upload:<imageId>` bzw. `url:<url>:<index>`). Ohne React-Import, damit der Test schlank bleibt (Muster `clock-font-size.ts`, `calendar-month.ts`).

2. `dashboard-images-api.ts` nach dem Muster `favorites-api.ts` (`API_URL` aus `NEXT_PUBLIC_API_URL`, `credentials: 'include'`): `DashboardImageMeta`, `fetchDashboardImages()`, `uploadDashboardImage(file: File)`, `deleteDashboardImage(id)`, `dashboardImageSrc(id)`. Test mit `vi.stubGlobal('fetch', …)`.

3. `picture-frame-widget.tsx` (`'use client'`, Props `WidgetProps`): `resolvePictureFrameConfig(config)` per `useMemo`; Zustand `index`, `brokenKeys: string[]`, `lightboxOpen`; sichtbare Einträge = alle ohne kaputte Schlüssel; `useEffect` mit `setInterval` nur wenn `intervalSeconds > 0 && visible.length > 1 && !lightboxOpen`, Räumung in der Aufräumfunktion; `random` über `pickNextIndex(…, Math.random)`. Darstellung: Rumpf `relative h-full w-full overflow-hidden`, `<img className={fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain'} referrerPolicy="no-referrer" loading="lazy" alt={caption ?? ''} onError=…>`, Unterschrift als `absolute inset-x-0 bottom-0 bg-black/50 px-2 py-1 text-xs text-white truncate` (nur wenn vorhanden). Außerhalb des Bearbeitungsmodus liegt Bild+Streifen in einem `<button type="button" aria-label={t('pictureFrame.open')} className="block h-full w-full cursor-zoom-in">`; im Bearbeitungsmodus in einem `<div>` (kein Handler — die Karte ist der Griff, Entscheidung 5). Leerzustand und „alle kaputt“ als zentrierter grauer Text (Entscheidung 7). Keine `dangerouslySetInnerHTML`. Kommentar im Dateikopf: warum der Server nie eine Adresse abruft (T-PI9-05).

4. `picture-frame-lightbox.tsx`: Props `{ src, caption, onClose }`; Aufbau wie `widget-catalog-modal.tsx` (`fixed inset-0 z-50`, Hintergrund als `<button aria-label={t('pictureFrame.close')} className="fixed inset-0 bg-black/80">`, Dialog `role="dialog" aria-modal="true" tabIndex={-1}` mit `ref.focus()` beim Einhängen, `keydown`-Escape-Listener mit Aufräumung), Schließen-Knopf oben rechts, `<img className="max-h-[85vh] max-w-[90vw] object-contain" referrerPolicy="no-referrer">`, Unterschrift darunter. Fokus-Rückgabe: das Widget merkt sich den Bild-Knopf per `useRef` und ruft nach dem Schließen `.focus()`.

5. `picture-frame-config-form.tsx` (`PictureFrameConfigForm({ config, onChange })`, Muster `ClockConfig`/`FavoritesConfig`: Labels `mb-1 block text-sm text-foreground`, Felder `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`): drei `<select>` (Bildausschnitt, Intervall mit sprechenden Texten aus `pictureFrame.intervalOff`/Sekunden/Minuten, Reihenfolge); Eintragsliste mit Vorschau 48×48 (`object-cover rounded bg-muted`), Unterschrift-Eingabe (Entwurf/Übernahme wie `commitFontSize`), Pfeil- und Entfernen-Knöpfe als echte `<button>` mit `aria-label` aus `pictureFrame.moveUpButton`/`moveDownButton`/`removeButton`; darunter versteckte `<input type="file" accept="image/png,image/jpeg,image/gif,image/webp">` hinter einem Knopf `pictureFrame.uploadButton` (`disabled` ab 30 Einträgen) und eine Zeile Texteingabe + Knopf `pictureFrame.urlAddButton` mit `isHttpsUrl`-Prüfung; Fehler als `<p role="alert" className="text-xs text-destructive">`. `onChange` bekommt bei Listenänderungen IMMER das vollständige `images`-Array (serverseitig flache Zusammenführung). Im `widget-settings-panel.tsx` einen Zweig `widget.widgetType === 'picture-frame'` ergänzen (gleiche Form wie die anderen fünf).

6. Verdrahtung: `widget-registry.tsx` — `WidgetType` um `'picture-frame'`, `WIDGET_CONSTRAINTS['picture-frame'] = { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }` (Begründung im Kommentar: Bild braucht Fläche, 8×8 = ein Quadrat wie die halbe Notiz), `PictureFrameIcon` (Inline-SVG: Rahmen `rect 3 3 18 18 rx 2`, `circle 8.5 8.5 r 1.5`, `polyline 21 15 16 10 5 21`), Registry-Eintrag mit `nameKey: 'pictureFrame.name'`, `descriptionKey: 'pictureFrame.description'`, `wirePictureFrameWidget`. `widget-catalog-modal.tsx` — `WIDGET_TYPES` ergänzen. `(portal)/page.tsx` — Import + `wirePictureFrameWidget(PictureFrameWidget)`. Tests nachziehen: `widget-registry.test.tsx` (Typliste und erwartete Constraints-Tabelle), `page.test.tsx` (`vi.mock` des neuen Widget-Moduls wie Zeile 59), `widget-catalog-modal.test.tsx` (Übersetzungsattrappe um `pictureFrame.name`/`description`, falls die Attrappe alle Namen aufzählt).

7. Übersetzungen `de.json`/`en.json`, Namensraum `widgets.pictureFrame` mit genau diesen Schlüsseln: `name` („Bilderrahmen“/„Picture frame“), `description`, `empty` („Noch keine Bilder — über die Einstellungen hinzufügen“), `unavailable` („Bild nicht verfügbar“), `open` („Bild groß anzeigen“), `close` („Großansicht schließen“), `fitLabel`, `fitContain` („Ganz sichtbar“), `fitCover` („Formatfüllend“), `intervalLabel` („Wechselintervall“), `intervalOff` („Kein Wechsel“), `intervalSeconds` („{n} Sekunden“), `intervalMinutes` („{n} Minuten“), `orderLabel`, `orderSequence` („Reihenfolge“), `orderRandom` („Zufall“), `imagesLabel` („Bilder“), `captionPlaceholder` („Bildunterschrift (optional)“), `uploadButton` („Bild hochladen“), `uploadHint` („PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder“), `urlPlaceholder` („https://…“), `urlAddButton` („Webadresse hinzufügen“), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“), `removeButton` („Bild entfernen“), `moveUpButton` („Nach oben“), `moveDownButton` („Nach unten“), `limitReached` („Die Höchstzahl von 30 Bildern ist erreicht.“), `uploadFailed` („Das Bild konnte nicht hochgeladen werden.“). Siezen, englische Entsprechungen in gleicher Tonlage.

Commit: `feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen`.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/lib/dashboard-images-api.test.ts "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && test "$(grep -rn 'dangerouslySetInnerHTML' apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx apps/web/src/components/settings/picture-frame-config-form.tsx | wc -l)" -eq 0 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.pictureFrame,e=require('./apps/web/src/messages/en.json').widgets.pictureFrame;const m=Object.keys(d).filter(k=>!(k in e));if(m.length){console.error('en fehlt:',m);process.exit(1)}" `picture-frame-config.test.ts` ≥ 10, `picture-frame-widget.test.tsx` ≥ 10, `picture-frame-config-form.test.tsx` ≥ 8, `dashboard-images-api.test.ts` ≥ 4 Fälle — alle grün, die Helfer-Tests nachweislich zuerst rot; bestehende Registry-/Katalog-/Seiten-Tests grün mit dem achten Typ. Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.pictureFrame`. Kette nachgewiesen (Tests): Datei wählen → `uploadDashboardImage` → `onChange` mit neuem Upload-Eintrag; https-Adresse → `onChange` mit URL-Eintrag; http-Adresse → Fehler, kein `onChange`; Kachel rendert Upload-Eintrag über `/api-proxy/dashboard/images/` und URL-Eintrag direkt mit `referrerPolicy="no-referrer"`; Wechsel, Pause bei Großansicht, Räumung beim Aushängen; kein Knopf im Bearbeitungsmodus. `as unknown as` in apps/web/src bleibt 6, keine `any`, kein `!`. Aufgabe 3: Changelog, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang CHANGELOG.md 1. `CHANGELOG.md` unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard“. 2. Volle Tore laufen lassen: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen (`as unknown as` api 27 / web 6, `grep -c '!\.' ` ist ungeeignet — `noNonNullAssertion` und `noExplicitAny` über `pnpm --filter @tessera/api exec biome lint src 2>&1 | grep -c ` oder die im Repo bereits genutzte Zählweise aus 260921-oxm) und die Zahlen ins SUMMARY schreiben. 3. Im SUMMARY eine Prüfliste für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver) hinterlegen, Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „Bilderrahmen“ mit Symbol; platzierte Kachel zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „Bilderrahmen #1“ aufklappen: PNG hochladen → Vorschau erscheint, Eintrag in `GET /dashboard/images`; (c) https-Adresse hinzufügen → Eintrag; http-Adresse → rote Meldung; (d) `.txt` als `.png` umbenannt hochladen → deutsche Meldung „Nur Bilder im Format …“; (e) Intervall 5 s, zwei Bilder → Kachel wechselt; Zufall mit drei Bildern → nie dasselbe zweimal hintereinander; Bildausschnitt umschalten → `object-cover`/`object-contain` sichtbar anders; (f) Klick auf das Bild → Großansicht mit Unterschrift, Escape schließt, Hintergrund-Klick schließt; während geöffnet kein Wechsel; (g) Bearbeitungsmodus: Klick öffnet nichts, Kachel lässt sich ziehen; (h) `curl -b -o /dev/null -w '%{http_code}' …/dashboard/images/` mit dem Cookie eines ZWEITEN Benutzers → 404; (i) Eintrag entfernen → Bild verschwindet aus `GET /dashboard/images`; (j) Netzwerk-Tab: Fremdbild wird vom Browser geladen, kein Aufruf der Fremdadresse durch die API (API-Log leer). Commit: `docs(quick-260921-pi9): Changelog - Bilderrahmen-Widget` (nur CHANGELOG.md; Akte/STATE macht der Orchestrator). cd /home/vicolab/projects/tessera-ctl && grep -q 'Bilderrahmen' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test Changelog-Zeile steht unter „Unveröffentlicht → Neu“; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1170 Tests, Web ≥ 560 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, `ts-expect-error` 0); die zehnpunktige Prüfliste steht im SUMMARY; genau drei Code/Doku-Commits mit Scope `quick-260921-pi9` (`git log --oneline -3`).

<assumption_delta_decision> Detektor gedanklich ausgeführt: feuert (Familie pluralization) — bislang hatte ein Widget-Bild genau EINE Quelle (Favoriten: Server-Proxy oder Direktbild als Ersatzkette, aber ein Datensatz); hier tritt die zweite Quelle (Upload NEBEN Webadresse) als gleichwertige Variante auf.

  • Primäres Nomen: die Bildquelle — PictureFrameEntry als Vereinigung mit kind-Unterscheider ('upload' | 'url').
  • Entscheidung: promote. Der allgemeine Eintragstyp ist die Primärdarstellung; imageId ist ein Detail der Upload-Variante, url ein Detail der URL-Variante. Es gibt KEINE parallele Liste imageIds: string[] neben urls: string[] — eine einzige geordnete Liste images, damit Reihenfolge, Unterschrift und Wechsel für beide Varianten dieselbe Logik durchlaufen.
  • Invariantentest (übernommen, in picture-frame-config.test.ts): resolvePictureFrameConfig akzeptiert beide Varianten in EINER Liste und erhält deren Reihenfolge; ein Eintrag ohne bekanntes kind fällt weg statt die Liste zu kippen.

API-Coverage-Detektor: feuert nicht — kein externer Dienst, keine SDK-Integration; der Browser lädt Fremdbilder, die API kennt nur ihre eigenen Zeilen. </assumption_delta_decision>

<threat_model> ASVS-Stufe 1, Blockschwelle high (jede high-Bedrohung MUSS mitigiert sein).

Vertrauensgrenzen

Grenze Beschreibung
Browser → API (multipart) Benutzer lädt beliebige Bytes unter beliebigem Namen/MIME hoch
Browser → API (:id) Benutzer nennt Bildkennungen — auch fremde
Config-JSON → Browser images[].url/caption stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config und landen in <img src>/Text
Browser → Fremdhost <img> ruft die Webadresse ab; der Fremdhost sieht Anfrage und ggf. Referrer
API → Datenbank bytea je Benutzer, Mandantentrennung über RLS (Schalter heute aus) + Anwendungsprüfung

STRIDE-Register

ID Kategorie Komponente Schwere Disposition Maßnahme
T-PI9-01 Tampering DashboardImagesService.upload high mitigate detectImageMime über Magic Bytes entscheidet Annahme UND gespeicherten mimeType; file.mimetype/Dateiendung werden nie ausgewertet; SVG/HTML/PDF-Polyglotte fallen durch (kein <svg, kein %PDF, kein Text). Auslieferung mit nosniff + CSP default-src 'none'; sandbox, damit auch ein unerwartet interpretierter Inhalt kein Skript im Tessera-Origin ausführt.
T-PI9-02 Denial of Service POST /dashboard/images medium mitigate FileInterceptor('image', { limits: { fileSize: 5 MiB, files: 1 } }) je Route (Muster T-M97-03); main.ts bleibt ohne globales Body-Limit.
T-PI9-03 Denial of Service Zähler 30 je Benutzer medium mitigate count({ where: { tenantId, userId } }) vor create im selben Dienst. Restrisiko (accept, low): zwei gleichzeitige Uploads desselben Benutzers können den Zähler um wenige Bilder überschreiten — kein Schaden über den eigenen Speicher hinaus, keine Transaktion nötig.
T-PI9-04 Information Disclosure (IDOR) GET/DELETE /dashboard/images/:id high mitigate Klient je Aufruf forTenant(prisma, tenantId, userId); Anwendungsprüfung row.userId === userId && row.tenantId === tenantId, sonst 404 (nie 403 — Existenz fremder Kennungen bleibt verborgen); RLS-Policy mit Benutzerdimension in der Migration; Kennungen uuid() (nicht erratbar). Getestet: fremder Benutzer UND fremder Mandant → 404.
T-PI9-05 Server-Side Request Forgery URL-Einträge high mitigate Die API ruft NIE eine Webadresse ab: kein Proxy-Endpunkt nimmt eine URL an, images[].url ist für die API ein undurchsichtiger JSON-Wert. Der Browser des Benutzers lädt das Bild selbst (<img>); interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (j).
T-PI9-06 Tampering (XSS) Unterschriften, Dateinamen medium mitigate Nur React-Textknoten, kein dangerouslySetInnerHTML (Verify-Gate in Aufgabe 2); originalName erscheint in keinem HTTP-Header (Content-Disposition: inline ohne filename) und nirgends als HTML; Unterschrift auf 200 Zeichen gekürzt.
T-PI9-07 Tampering (Mixed Content / gefährliche Schemata) images[].url medium mitigate isHttpsUrl (echter URL-Parser, protocol === 'https:') im Formular UND in resolvePictureFrameConfig beim Rendern — http:, data:, javascript:, file: werden nie zum src. Serverseitig nicht prüfbar (API kennt keine Config-Inhalte, siehe Entscheidung 4) — Risiko bleibt auf das eigene Dashboard beschränkt.
T-PI9-08 Spoofing (Content-Type) GET /dashboard/images/:id medium mitigate Content-Type ausschließlich aus dem per Magic Bytes bestimmten, gespeicherten mimeType (eine der vier Bild-Konstanten), X-Content-Type-Options: nosniff.
T-PI9-09 Information Disclosure (Referrer) <img> auf Fremdhost low mitigate referrerPolicy="no-referrer" an jedem <img> (Widget, Großansicht, Vorschau im Formular) — der Fremdhost erfährt die Tessera-Adresse nicht.
T-PI9-10 Information Disclosure (Caches) Auslieferung eigener Bilder low mitigate Cache-Control: private, max-age=86400 — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) darf die Antwort für andere ausliefern.
T-PI9-11 Elevation of Privilege multipart-Rumpf medium mitigate Mandant/Benutzer nur aus @CurrentUser() (Sitzungsnachweis); der Rumpf hat genau das Feld image, keine DTO-Felder für tenantId/userId (Muster T-M97-06).
T-PI9-12 Repudiation Löschen/Hochladen low accept Kein Audit-Log für Bilder — persönliche Inhalte ohne Fremdwirkung; Zeitstempel createdAt reicht für ASVS 1.
T-PI9-SC Tampering (Lieferkette) npm-Installationen high mitigate Nicht ausgelöst: KEINE neuen Pakete — multer kommt über das vorhandene @nestjs/platform-express, Magic-Byte-Erkennung ist eine Handvoll eigener Zeilen (kein file-type-Paket). Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator.
</threat_model>
Automatisch (Executor, je Aufgabe im ``): API-Tests `src/dashboard` + `src/prisma`, Web-Tests der neuen und angefassten Dateien, `tsc --noEmit` beider Apps, Biome, Zähler `as unknown as`, Schlüsselgleichheit de/en, kein `dangerouslySetInnerHTML`, Migration im Commit.

Am Ende (Aufgabe 3): pnpm type-check 4/4, pnpm lint 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung.

Manuell (Orchestrator, Prüfliste aus Aufgabe 3 Punkt 3, lokal im Browser): Katalog, Upload, https/http, Nicht-Bild, Wechsel/Zufall/Ausschnitt, Großansicht, Bearbeitungsmodus, 404 für fremde Kennung, Löschen, kein Server-Abruf der Fremdadresse.

<success_criteria>

  • Alle sieben must_haves.truths erfüllt und je mit Test oder Rundgangspunkt belegt
  • Migration 20260921120000_dashboard_image angewendet, rls-coverage und rls-access-inventory grün (30/30 in src/prisma)
  • Fremde Kennung → 404 (Benutzer UND Mandant), Nicht-Bild → 400 deutsch, > 5 MiB → 413, 31. Bild → 400 deutsch
  • Widget: Leerzustand, Wechsel (Reihenfolge/Zufall), Ausschnitt, Unterschrift, Großansicht mit Fokus-Rückgabe, kein Klick im Bearbeitungsmodus, Timer geräumt
  • Formular: Upload, https-Adresse, Abweisung http, Unterschrift, Pfeile, Entfernen (mit Server-Löschung), „Bild nicht verfügbar“
  • Beide Sprachdateien vollständig, Texte siezen
  • Changelog-Stichpunkt unter „Unveröffentlicht → Neu“
  • Tore grün, Zähler unverändert, keine neue any, drei Commits mit Scope quick-260921-pi9 </success_criteria>
Nach Abschluss `.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md` anlegen (Muster `260921-oxm-SUMMARY.md`): Rot-Nachweis der Helfer-/Diensttests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.