docs(quick-260921-pi9): Akte - Bilderrahmen-Widget gebaut, zehnpunktiger Browser-Rundgang bestanden
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

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>
This commit is contained in:
2026-09-21 19:22:32 +02:00
parent 20a9eb2c8c
commit 8686b1a673
3 changed files with 553 additions and 5 deletions
@@ -0,0 +1,287 @@
---
phase: quick-260921-pi9
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260921-PI9]
files_modified:
- 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
estimate:
tokens: 260000
raw_tokens: 260000
tasks: 3
confidence: low
must_haves:
truths:
- "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."
artifacts:
- "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“"
key_links:
- "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“
<objective>
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.
</objective>
## 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>
<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
</context>
## 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.
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1: API — Modell, Migration, Regeln, Dienst, Controller (Ende-zu-Ende „Bild hochladen und wieder abrufen“)</name>
<files>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</files>
<behavior>
- `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'`.
</behavior>
<action>
**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).
</action>
<verify>
<automated>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'</automated>
</verify>
<done>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/<id>` 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 `!`.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Web — Widget, Großansicht, Einstellungsformular, Katalog, Übersetzungen</name>
<files>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</files>
<behavior>
- `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 → `<img>` 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/<id>"`; 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 `<img>` 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 (`<img>` 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: '<id aus Antwort>' }] })`; 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`.
</behavior>
<action>
**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`.
</action>
<verify>
<automated>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)}"</automated>
</verify>
<done>`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/<id>` 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 `!`.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Changelog, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
<files>CHANGELOG.md</files>
<action>
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 <regel>` 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 <cookie> -o /dev/null -w '%{http_code}' …/dashboard/images/<id>` 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).
</action>
<verify>
<automated>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</automated>
</verify>
<done>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`).</done>
</task>
</tasks>
<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>
<verification>
Automatisch (Executor, je Aufgabe im `<verify>`): 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.
</verification>
<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>
<output>
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.
</output>
@@ -0,0 +1,260 @@
---
phase: quick-260921-pi9
plan: 01
subsystem: apps/api/src/dashboard, apps/web/src/components/dashboard/widgets, apps/web/src/components/settings
tags: [dashboard, widget, bilderrahmen, upload, bytea, magic-bytes, rls, tdd, i18n]
status: complete
requires:
- "STATE.md „NAECHSTER AUFTRAG“: erstes der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
- "Migration 20260911120000 (Regelform mit Benutzerdimension)"
provides:
- "Widget-Typ picture-frame: Diashow aus hochgeladenen Bildern und https-Adressen mit Grossansicht"
- "API dashboard/images: Bilder je Benutzer als bytea, Magic-Byte-Pruefung, 5 MiB / 30 Stueck, Besitz = Mandant UND Benutzer"
- "Bildverwaltung im WidgetSettingsPanel (Einstellungen -> Dashboard)"
affects:
- "apps/api/prisma/schema.prisma (neues Modell DashboardImage)"
- "apps/api/src/dashboard/*"
- "apps/web/src/components/dashboard/widget-registry.tsx (achter Typ)"
- "apps/web/src/messages/de.json, en.json (Namensraum widgets.pictureFrame)"
- "docs/mandantentrennung-zugriffsklassifikation.md (neues Paar, Zahlen nachgemessen)"
tech-stack:
added: []
patterns:
- "Magic-Byte-Erkennung als reine Funktion (kein file-type-Paket), entscheidet Annahme UND gespeicherten Typ"
- "Eintragstyp als Vereinigung mit kind-Unterscheider in EINER geordneten Liste (promote, kein Listenpaar)"
- "Fremdbilder laedt nur der Browser (<img referrerPolicy=no-referrer>), die API kennt keinen URL-Proxy"
- "Prisma-Bytes: new Uint8Array(buffer) statt Zusicherung (TS 5.9 verlangt Uint8Array<ArrayBuffer>)"
key-files:
created:
- 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/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
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/dashboard/dashboard.module.ts
- apps/api/src/dashboard/dto/create-widget.dto.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- 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
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
decisions:
- "Prisma-Bytes ohne Zusicherung: new Uint8Array(file.buffer) kopiert einmal je Upload (hoechstens 5 MiB) — der Plan-Hinweis „data: file.buffer geht“ stimmt unter TS 5.9 + Prisma 6 nicht, der Compiler lehnt Buffer<ArrayBufferLike> ab"
- "Widget-Test 11 prueft die Pause des Wechsels ueber das Verhalten (vier Intervalle vergehen, Bild bleibt), nicht ueber vi.getTimerCount(): React haelt nach einer Interaktion selbst einen Scheduler-Timer (gemessen 1), der Zaehler misst also nicht nur unseren Timer"
- "Klassifikationsdokument: Bereichs- und Summenzeilen nachgemessen statt +6 addiert — settings (3 -> 4) und bug-reports waren seit 260914-m97 in der Summe nie mitgezaehlt, die Paarzahl der Klassen-Verteilung stand auf 72 bei tatsaechlich 73 Zeilen; jetzt 187 gebunden / 74 Paare, beides der Messung entnommen"
- "Vorschau im Formular aria-hidden (dekorativ, die Unterschrift traegt den Sinn); das Kachelbild behaelt sein alt und damit eine Biome-Warnung der Stufe warn (onError auf <img> gilt der a11y-Regel als Interaktion — Fehlbefund)"
metrics:
duration: "ca. 75 min (18:35 bis 19:50 Uhr, 21.09.2026)"
completed: 2026-09-21
actuals:
tokens: 36000
tasks: 3
commits: 3
plan_head_before: 573d070
---
# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“ Summary
Ein neues Dashboard-Widget zeigt eigene Bilder als Diashow: hochgeladen (in
der Datenbank, dem Benutzer gehoerend, 5 MiB je Datei, 30 je Benutzer) oder
per https-Adresse eingebunden (der Browser laedt sie direkt, der Server ruft
nie eine Adresse ab). Bildausschnitt, Wechselintervall, Reihenfolge/Zufall und
Bildunterschrift stellt der Benutzer unter Einstellungen -> Dashboard ein; ein
Klick zeigt das Bild gross. Alle Tore sind gruen, der curl-Rundgang lief gegen
die lebende lokale API.
## Was gebaut wurde
**API (Commit 737974b).** Prisma-Modell `DashboardImage` (`data Bytes`, keine
Relation) mit handgeschriebener Migration `20260921120000_dashboard_image`:
Tabelle, beide Indizes, `ENABLE`/`FORCE ROW LEVEL SECURITY` und
`tenant_isolation_policy` mit Benutzerdimension von Anfang an. Die Migration
ist lokal angewendet (`prisma migrate status`: keine ausstehende), `prisma
generate` gelaufen. `dashboard-image-rules.ts` erkennt PNG/JPEG/GIF/WebP an
den Magic Bytes — `file.mimetype` und Dateiendung werden nie gelesen, der
erkannte Typ ist zugleich der gespeicherte und der spaeter ausgelieferte.
`DashboardImagesService` (list/upload/getBytes/remove) holt je Methode
`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und
Zaehler filtern explizit `{ tenantId, userId }`, `getBytes`/`remove` pruefen
Besitz gegen Mandant UND Benutzer und antworten sonst 404 (nie 403).
`DashboardImagesController` unter `dashboard/images`: `GET` -> `POST`
(`FileInterceptor('image', 5 MiB, eine Datei)`) -> `GET :id` (Content-Type
aus dem gespeicherten Typ, `Cache-Control: private, max-age=86400`,
`X-Content-Type-Options: nosniff`, `Content-Disposition: inline` ohne
Dateinamen, CSP `default-src 'none'; sandbox`) -> `DELETE :id`. Kein
`@Roles`. `CreateWidgetDto` kennt `'picture-frame'`.
**Web (Commit c080580).** `picture-frame-config.ts`: `PictureFrameEntry` als
Vereinigung (`upload` | `url`) in EINER Liste, `resolvePictureFrameConfig`
laesst alles weg, was der URL-Parser nicht als `https:` erkennt (T-PI9-07:
die API prueft Config-Inhalte nicht, deshalb entscheidet allein diese Funktion,
was zum `src` wird), Intervall 0 oder 5..3600 s (Vorgabe 30), `pickNextIndex`
(Zufall zieht aus count-1 Kandidaten, nie das aktuelle). `dashboard-images-api.ts`
schickt die Datei als FormData-Feld `image` ohne eigenen Content-Type, macht
aus 413 die deutsche Meldung, 400-Meldungen kommen bereits deutsch von der API.
`PictureFrameWidget`: Leerhinweis im Stil der anderen Widgets, `<img
referrerPolicy="no-referrer">` (Upload ueber `/api-proxy/dashboard/images/:id`,
URL direkt), `object-contain`/`object-cover`, Unterschrift als Streifen, Timer
nur bei > 1 Bild und Intervall > 0 und geschlossener Grossansicht (Raeumung im
Cleanup), kaputte Bilder verlassen den Umlauf („Bild nicht verfuegbar“, wenn
alle). Ausserhalb des Bearbeitungsmodus liegt das Bild in einem `<button>`
(Grossansicht `PictureFrameLightbox`: Dialog fokussiert, Escape/Hintergrund/
Schliessen-Knopf, danach Fokus zurueck am Bild-Knopf); im Bearbeitungsmodus ein
`<div>` ohne Handler — die Karte bleibt der Ziehgriff. `PictureFrameConfigForm`
im WidgetSettingsPanel: drei Auswahlfelder (senden nur ihr Feld), Eintragsliste
mit Vorschau/Unterschrift (Entwurf, Uebernahme bei Blur/Enter)/Pfeilen/Entfernen
(Upload wird auch serverseitig geloescht, Fehler verschluckt), Datei hochladen
(deaktiviert ab 30), Webadresse hinzufuegen (http -> `role="alert"`, kein
`onChange`). Listenaenderungen senden IMMER das ganze `images`-Array. Registry
(`minW 4, minH 4, defaultW 8, defaultH 8`), Katalog, Seite, 28 Schluessel je
Sprache unter `widgets.pictureFrame`.
**Doku (Commit c3b4597).** Changelog-Stichpunkt als erster unter
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle und Absatz unter
„Dashboard > Widgets“ im Anwenderhandbuch, zwei Woerter auf der Erlaubnisliste
des Umlaut-Waechters (siehe Deviations).
## Die Tests, und der Beleg dass sie rot waren
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|---|---:|---|
| `dashboard-image-rules.spec.ts` | 10 | `pnpm --filter @tessera/api exec vitest run src/dashboard/dashboard-image-rules.spec.ts` -> `Error: Cannot find module './dashboard-image-rules'`, 1 Test File failed, 10 Faelle nicht ausfuehrbar |
| `dashboard-images.service.spec.ts` | 12 | `... vitest run src/dashboard/dashboard-images.service.spec.ts` -> `Cannot find module './dashboard-images.service'`, 1 failed |
| `dashboard-images.controller.spec.ts` | 5 | `... vitest run src/dashboard/dashboard-images.controller.spec.ts` -> `Cannot find module './dashboard-images.controller'`, 1 failed |
| `picture-frame-config.test.ts` | 11 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/picture-frame-config.test.ts src/lib/dashboard-images-api.test.ts` -> `Failed to resolve import "./picture-frame-config"`, 2 Test Files failed |
| `dashboard-images-api.test.ts` | 5 | derselbe Lauf -> `Failed to resolve import "./dashboard-images-api"` |
| `picture-frame-widget.test.tsx` | 12 | nach der Umsetzung geschrieben (Plan verlangt Rot nur fuer Regel-/Dienst-/Helfer-Tests); erster Lauf 11/12, Test 11 wegen des React-Scheduler-Timers umgestellt (siehe decisions) |
| `picture-frame-config-form.test.tsx` | 9 | nach der Umsetzung geschrieben; erster Lauf 9/9 |
Zusammen 64 neue Faelle; die bestehenden Registry-/Katalog-/Seiten-Tests laufen
mit dem achten Typ (Constraints-Tabelle, `counted` 28 -> 32, Attrappen um
`pictureFrame.*` und das neue Widget-Modul ergaenzt).
## curl-Rundgang gegen die lebende API
Die Container `api`/`web` lagen mit einem alten Image still; die API lief
deshalb aus dem Quelltext (`nest build` + `node dist/main.js`) gegen eine
eigens angelegte, leere Datenbank `tessera_pi9` auf dem lokalen db-Container
(Migrationen angewendet, Admin per Erstanlage), danach wieder geloescht. Kein
Zugriff auf den Testserver.
| Schritt | Ergebnis |
|---|---|
| `POST /dashboard/images` mit 4x4-PNG | 201, `{ id, originalName, mimeType: "image/png", size: 73, createdAt }` |
| `GET /dashboard/images` | 200, Liste mit denselben fuenf Feldern, kein `data` |
| `GET /dashboard/images/<id>` | 200, `Content-Type: image/png`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, `Content-Security-Policy: default-src 'none'; sandbox`; Bytes per `cmp` identisch mit der Quelle |
| Textdatei als `.png` (`type=image/png`) | 400 `Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.` |
| 6-MiB-Datei | 413 `File too large` (multer/Nest, im Web-Klienten deutsch) |
| erfundene Kennung | 404 |
| ohne Cookie | 401 |
| Kennung mit dem Cookie eines ZWEITEN Benutzers (`GET` und `DELETE`) | 404 / 404 (Punkt (h) der Pruefliste bereits erledigt) |
| eigener `DELETE` | 200 `{ id }`, Liste danach `[]` |
## Messungen (Endstand, HEAD c3b4597)
| Groesse | Ausgang (573d070) | Jetzt |
|---|---:|---:|
| `pnpm type-check` | 4/4 | 4/4 |
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
| API-Tests | 1148 | **1175** (75 Dateien) |
| Web-Tests | 531 | **569** (77 Dateien) |
| `as unknown as` in apps/api/src | 27 | 27 |
| `as unknown as` in apps/web/src | 6 | 6 |
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
| `biome-ignore` in apps/api/src | 1 | 1 |
| `ts-expect-error` | 0 | 0 |
| `dangerouslySetInnerHTML` in den drei neuen Komponenten | – | 0 |
| RLS-Waechter `src/prisma` | 30/30 laut Plan | 78/78 (davon `rls-coverage` + `rls-access-inventory` 35/35) |
| de/en-Schluesselgleichheit `widgets.pictureFrame` | – | 28 = 28 |
Keine neue `any`, kein `!`, kein neues Paket.
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP)
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufuegen“ zeigt „Bilderrahmen“ mit Rahmen-Symbol; die platzierte Kachel (8x8) zeigt „Noch keine Bilder — ueber die Einstellungen hinzufuegen“
- [x] (b) Einstellungen -> Dashboard -> „Bilderrahmen #1“ aufklappen: PNG hochladen -> Vorschau erscheint in der Liste, `GET /dashboard/images` enthaelt den Eintrag ohne `data`
- [x] (c) https-Adresse hinzufuegen -> Eintrag mit Vorschau; http-Adresse -> rote Meldung „Bitte geben Sie eine vollstaendige https-Adresse ein.“, kein Eintrag
- [x] (d) `.txt` als `.png` umbenannt hochladen -> rote Meldung „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“
- [x] (e) Intervall 5 s, zwei Bilder -> Kachel wechselt; Zufall mit drei Bildern -> nie dasselbe zweimal hintereinander; Bildausschnitt umschalten -> `object-cover` / `object-contain` sichtbar anders
- [x] (f) Klick auf das Bild -> Grossansicht mit Unterschrift; Escape schliesst, Hintergrund-Klick schliesst, Schliessen-Knopf schliesst; waehrend geoeffnet kein Wechsel
- [x] (g) Bearbeitungsmodus: Klick auf das Bild oeffnet nichts, die Kachel laesst sich an jeder Stelle ziehen
- [x] (h) `curl -b <cookie zweiter Benutzer> -o /dev/null -w '%{http_code}' .../dashboard/images/<id>` -> 404 (bereits mit curl gegen die lokale API belegt, siehe Rundgang oben; im Browser optional wiederholen)
- [x] (i) Eintrag entfernen -> Bild verschwindet aus `GET /dashboard/images` und aus der Kachel
- [x] (j) Netzwerk-Tab: das Fremdbild laedt der Browser selbst (Anfrage an den Fremdhost mit `Referrer Policy: no-referrer`), im API-Log kein Aufruf der Fremdadresse
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus HEAD, Playwright-MCP):** alle zehn Punkte bestanden. Belege: (a) Katalog zeigt „Bilderrahmen“ mit Beschreibung, Leerhinweis in der Kachel; (b) `rot.png` hochgeladen, Vorschau 320 px, `GET /dashboard/images` liefert `{id, originalName, mimeType, size, createdAt}` ohne `data`; (c) `http://example.com/bild.png` → Meldung, kein Eintrag; `https://www.gstatic.com/webp/gallery/1.webp` → Eintrag mit Vorschau (eine zuvor eingetragene, serverseitig 400 liefernde Wikimedia-Adresse zeigte korrekt „Bild nicht verfügbar“); (d) Textdatei als `.png` → „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“; (e) Intervall 5 s: blau → gstatic → rot → blau im Sekundentakt gemessen; Zufall mit drei Bildern: 8 Wechsel in 42 s, nie dasselbe zweimal hintereinander; `object-cover` nach Umschalten; (f) Grossansicht nach Portal-Korrektur 8bf3601 ueber den ganzen Viewport (Hintergrund 1905x949), Escape schliesst mit Fokusrueckgabe auf „Bild groß anzeigen“, Hintergrund-Klick schliesst, waehrend geoeffnet 6 s lang kein Wechsel; (g) Bearbeitungsmodus: kein Bild-Knopf im Widget, Ziehen ueber die Bildflaeche verschiebt die Kachel (`translate` 8 → 480 px), kein Dialog; (h) siehe curl; (i) zweiten Eintrag entfernt → `GET /dashboard/images` nur noch `rot.png`, Config nur noch zwei Eintraege; (j) Netzwerk: gstatic-Abruf kommt vom Browser, API-Log ohne Treffer auf `gstatic`.
**Drei Befunde aus dem Rundgang, behoben in 8bf3601:** (1) die Grossansicht war auf die Kachelflaeche (531x216) beschraenkt — die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen; jetzt `createPortal` in `document.body` wie der Kalender-Tooltip; (2) „1 Minuten“ im Wechselintervall → ICU-Plural in de/en, Formular-Test nutzt dafuer `createTranslator` von next-intl auf der echten de.json; (3) Standardgroesse 8x8 (216 px hoch) zu flach → 8x12 wie der Kalender. Web-Tests 569 unveraendert in der Zahl (ein Fall um die Singular-Pruefung ergaenzt).
## Deviations from Plan
1. **[Rule 1 - Bug] `data: file.buffer` kompiliert nicht.** Der Executor-Hinweis
„`data: file.buffer` beim Anlegen geht“ stimmt unter TS 5.9 + Prisma 6.19
nicht: `Bytes` verlangt `Uint8Array<ArrayBuffer>`, multers `Buffer` ist
ueber `ArrayBufferLike` getypt und wird abgelehnt (TS2322). Statt einer
Zusicherung kopiert `new Uint8Array(file.buffer)` einmal je Upload
(hoechstens 5 MiB). Aufgabe 1, Commit 737974b.
2. **[Rule 3 - Blocking] Umlaut-Waechter.** Der volle Web-Testlauf meldete die
neuen de.json-Woerter „Bildausschnitt“ und „Webadresse“ als unbekannte
ss-Tokens. Beide sind korrektes Deutsch und stehen jetzt auf
`UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts` (Datei
nicht im Plan). Aufgabe 3, Commit c3b4597.
3. **Verify-Skript Aufgabe 1:** `git show --stat` kuerzt den Migrationspfad
auf `.../20260921120000_dashboard_image/migration.sql`, der `grep` des
Plans auf den vollen Pfad schlaegt deshalb fehl; mit `--stat=200` ist die
Migration im Commit eindeutig nachgewiesen. Kein Code-Befund.
4. **Klassifikationsdokument, mehr als die geplante eine Zeile:** die
Nachmessung mit der Gate-Schleife ergab, dass Bereichs- und Summenzeilen
bereits vor dieser Aufgabe um zwei Rohtreffer (settings 3 statt 4,
bug-reports nie summiert) und die Klassen-Verteilung um ein Paar
(bug-reports) hinterherhingen. Beides ist nachgezogen und im Dokument als
Nachtrag 260921-pi9 begruendet; der Waechter `rls-access-inventory` prueft
nur die Paartabelle und war davon nicht betroffen.
5. **Widget-Test 11** misst die Pause des Wechsels ueber das Verhalten statt
ueber `vi.getTimerCount()` (siehe decisions).
6. **Zusatz des Orchestrators umgesetzt:** Zeile und Absatz in
`docs/anleitung-anwender.md` im Aufgabe-3-Commit.
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
Deploy, kein Zugriff auf den Testserver.
## Known Stubs
Keine. Jede Kette ist verdrahtet: Datei -> Upload -> Config -> Kachel -> Proxy
-> API -> Bytes; https-Adresse -> Config -> Kachel -> Browser.
## Threat Flags
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: die vier Routen
unter `dashboard/images` und die `<img>`-Fremdabrufe sind dort als T-PI9-01
bis T-PI9-11 erfasst und mitigiert; `Content-Disposition` ohne Dateinamen und
`Cache-Control: private` sind mit curl belegt.
## Self-Check: PASSED
Alle 16 neu angelegten Dateien liegen auf der Platte, die drei Commits
737974b, c080580 und c3b4597 sind in `git log` auffindbar
(`git rev-list --count 573d070..HEAD` = 3). Die Zahlen der Tabelle stammen
aus tatsaechlich gelaufenen Befehlen.