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>
47 KiB
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-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)
- Speicherung als
byteaim neuen Prisma-ModellDashboardImage(kein Docker-Volume; Sicherung deckt es mit ab). Migration als SQL-Datei, angewendet mitprisma migrate deploy— niedb push. - 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. - Endpunkte unter
dashboard/images(angemeldet):GET(eigene Liste, nur Metadaten),POST(multipart-Feldimage),GET :id(Binär mit den genannten Headern),DELETE :id. Besitz = gleicher Mandant UND gleicher Benutzer; fremde Kennung → 404. Statische Route vor:id. - 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 —UpdateWidgetConfigDtoträgt nur@IsObject(),DashboardService.updateWidgetConfigfü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 (resolvePictureFrameConfiglässt jede Nicht-https-Adresse weg). Ein manipulierter Config-Wert schadet damit nur dem eigenen Dashboard und wird dort nicht einmal gerendert. - 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). - Wechsel per Timer; Zufall wählt bei >1 Bild nie das aktuelle; Timer beim Aushängen geräumt; pausiert bei offener Großansicht.
- Leerzustand: „Noch keine Bilder — über die Einstellungen hinzufügen“, Stil
flex h-full items-center justify-center text-sm text-muted-foreground(wiePlaceholderWidget/Favoriten-empty). - 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(MusterFavoriteIcon, favorites-widget.tsx). - Texte Deutsch mit „Sie“, plus Englisch; keine kundenspezifischen Vorgaben.
- 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>
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; danachpnpm --filter @tessera/api exec prisma generate. Läuft der Container nicht:docker compose up -d db. Nie auf den Testserver deployen, nieprisma db push. - Tore vor jedem Commit:
pnpm type-check,pnpm lint, die betroffenen Vitest-Dateien; am Endepnpm --filter @tessera/api testundpnpm --filter @tessera/web testvollständig. - Wächter:
rls-coverage.spec.tsliest Schema und Migrationen (RegexALTER TABLE "X" ENABLE ROW LEVEL SECURITYundCREATE POLICY \w+ ON "X");rls-access-inventory.spec.tsverlangtforTenant(nur in der Zuweisungsformconst X = forTenant(, jedeselect:-Angabe innerhalb eines Modellaufrufs, und je (Datei, Modell) eine Zeile indocs/mandantentrennung-zugriffsklassifikation.md(Tabelle „| Datei | Modell | Klasse | Stand | Begründung |“, Zeile ~660). - Prisma 6:
BytesistUint8Array, nichtBuffer.data: file.bufferbeim Anlegen geht (Buffer ist eine Uint8Array-Unterklasse); bei der Auslieferungres.send(Buffer.from(row.data.buffer, row.data.byteOffset, row.data.byteLength))— keine Zusicherung nötig. - multer:
LIMIT_FILE_SIZEbildet 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 neueas unknown as, kein!. Multipart-Datei als bestehender TypUploadedFileLike(apps/api/src/auth/types/auth-user.ts), Aufrufer alsAuthUserüber@CurrentUser(). - Commits: je Aufgabe genau ein Commit, Stil wie
git log --oneline -15, Scopequick-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.
**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 → `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 —
PictureFrameEntryals Vereinigung mitkind-Unterscheider ('upload' | 'url'). - Entscheidung:
promote. Der allgemeine Eintragstyp ist die Primärdarstellung;imageIdist ein Detail der Upload-Variante,urlein Detail der URL-Variante. Es gibt KEINE parallele ListeimageIds: string[]nebenurls: string[]— eine einzige geordnete Listeimages, damit Reihenfolge, Unterschrift und Wechsel für beide Varianten dieselbe Logik durchlaufen. - Invariantentest (übernommen, in
picture-frame-config.test.ts):resolvePictureFrameConfigakzeptiert beide Varianten in EINER Liste und erhält deren Reihenfolge; ein Eintrag ohne bekannteskindfä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> |
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.truthserfüllt und je mit Test oder Rundgangspunkt belegt - Migration
20260921120000_dashboard_imageangewendet,rls-coverageundrls-access-inventorygrün (30/30 insrc/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 Scopequick-260921-pi9</success_criteria>