diff --git a/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql b/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
new file mode 100644
index 0000000..b36bc05
--- /dev/null
+++ b/apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
@@ -0,0 +1,54 @@
+-- 260921-pi9 — Bilderrahmen-Widget: hochgeladene Bilder eines Benutzers.
+--
+-- Zweck: die Tabelle "DashboardImage" traegt die Bilddaten (bytea) fuer das
+-- Dashboard-Widget „Bilderrahmen“. Bilder liegen in der Datenbank statt in
+-- einem Docker-Volume, damit die bestehende Sicherung sie mit abdeckt.
+--
+-- Grenzen (durchgesetzt in der Anwendung, apps/api/src/dashboard/
+-- dashboard-image-rules.ts + dashboard-images.service.ts): hoechstens 5 MiB
+-- je Datei (multer-Limit je Route), hoechstens 30 Bilder je Benutzer
+-- (Zaehler je Mandant+Benutzer vor dem Anlegen); erlaubt sind nur PNG, JPEG,
+-- GIF und WebP, erkannt an den Magic Bytes — "mimeType" ist der ERKANNTE Typ,
+-- nie der vom Browser behauptete.
+--
+-- Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
+-- gleicher Benutzer). Die Regel unten traegt deshalb von Anfang an die
+-- Benutzerdimension (Form aus 20260911120000_rls_user_dimension_personal_
+-- tables); die Anwendung prueft den Besitz zusaetzlich in getBytes/remove und
+-- antwortet fuer fremde Kennungen mit 404 (nie 403).
+--
+-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
+-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
+--
+-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirkt die Regel erst, wenn die
+-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
+-- docs/mandantentrennung-datenbankrolle.md).
+
+-- CreateTable
+CREATE TABLE "DashboardImage" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "tenantId" TEXT NOT NULL,
+ "originalName" TEXT NOT NULL,
+ "mimeType" TEXT NOT NULL,
+ "size" INTEGER NOT NULL,
+ "data" BYTEA NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DashboardImage_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "DashboardImage_userId_idx" ON "DashboardImage"("userId");
+
+-- CreateIndex
+CREATE INDEX "DashboardImage_tenantId_idx" ON "DashboardImage"("tenantId");
+
+-- Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000)
+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())
+ );
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 1278451..c09cad0 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -212,6 +212,26 @@ model WidgetInstance {
@@index([tenantId])
}
+// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
+// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
+// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
+// Benutzer) und die Magic-Byte-Erkennung leben in
+// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
+// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
+model DashboardImage {
+ 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])
+}
+
model SearchProvider {
id String @id @default(uuid())
userId String?
diff --git a/apps/api/src/dashboard/dashboard-image-rules.spec.ts b/apps/api/src/dashboard/dashboard-image-rules.spec.ts
new file mode 100644
index 0000000..57973cc
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-image-rules.spec.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DASHBOARD_IMAGE_MAX_BYTES,
+ DASHBOARD_IMAGE_MAX_COUNT,
+ detectImageMime,
+} from './dashboard-image-rules';
+
+/**
+ * dashboard-image-rules.spec — NEU (quick-260921-pi9, Bilderrahmen-Widget).
+ *
+ * Die Magic-Byte-Erkennung entscheidet Annahme UND gespeicherten Typ eines
+ * hochgeladenen Bildes (T-PI9-01); Dateiname und behaupteter MIME-Typ werden
+ * nie gelesen. Deshalb wird sie hier direkt an den Bytes geprueft: vier
+ * gueltige Signaturen, und alles, was NICHT durchgehen darf (leer, Text,
+ * SVG, PDF, RIFF ohne WEBP).
+ */
+function bytes(...parts: (number[] | string)[]): Uint8Array {
+ const out: number[] = [];
+ for (const p of parts) {
+ if (typeof p === 'string') {
+ for (const ch of p) out.push(ch.charCodeAt(0));
+ } else {
+ out.push(...p);
+ }
+ }
+ return Uint8Array.from(out);
+}
+
+describe('detectImageMime (quick-260921-pi9)', () => {
+ it('Test 1: PNG-Signatur 89 50 4E 47 0D 0A 1A 0A -> image/png', () => {
+ expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13]))).toBe(
+ 'image/png',
+ );
+ });
+
+ it('Test 2: FF D8 FF -> image/jpeg', () => {
+ expect(detectImageMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
+ });
+
+ it('Test 3: GIF87a und GIF89a -> image/gif', () => {
+ expect(detectImageMime(bytes('GIF87a', [1, 0, 1, 0]))).toBe('image/gif');
+ expect(detectImageMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
+ });
+
+ it('Test 4: RIFF????WEBP -> image/webp', () => {
+ expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe('image/webp');
+ });
+
+ it('Test 5: leerer Puffer -> null', () => {
+ expect(detectImageMime(new Uint8Array(0))).toBeNull();
+ });
+
+ it('Test 6: Textdatei -> null, auch wenn sie lang genug ist', () => {
+ expect(detectImageMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
+ });
+
+ it('Test 7: SVG-Text und PDF (%PDF) -> null', () => {
+ expect(detectImageMime(bytes(''))).toBeNull();
+ expect(detectImageMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
+ });
+
+ it('Test 8: RIFF-Kopf ohne WEBP an Stelle 8 (z. B. WAVE) -> null', () => {
+ expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WAVE', 'fmt '))).toBeNull();
+ });
+
+ it('Test 9: zu kurze Puffer werfen nicht — PNG-Anfang mit 4 Bytes ist null, JPEG-Anfang mit 2 Bytes ist null', () => {
+ expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
+ expect(detectImageMime(bytes([0xff, 0xd8]))).toBeNull();
+ expect(detectImageMime(bytes('RIFF'))).toBeNull();
+ });
+
+ it('Test 10: Grenzen — 5 MiB je Datei, 30 Bilder je Benutzer', () => {
+ expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
+ expect(DASHBOARD_IMAGE_MAX_COUNT).toBe(30);
+ });
+});
diff --git a/apps/api/src/dashboard/dashboard-image-rules.ts b/apps/api/src/dashboard/dashboard-image-rules.ts
new file mode 100644
index 0000000..eebdf80
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-image-rules.ts
@@ -0,0 +1,51 @@
+/**
+ * dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget
+ * (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die
+ * Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte
+ * anwenden und die Erkennung direkt an den Bytes testbar ist.
+ *
+ * Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung
+ * (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte
+ * HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier
+ * Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte
+ * Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08).
+ *
+ * Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind
+ * eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
+ */
+
+/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */
+export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
+
+/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */
+export const DASHBOARD_IMAGE_MAX_COUNT = 30;
+
+export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
+
+const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
+const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a"
+const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a"
+const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
+const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
+
+function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean {
+ if (buffer.length < offset + signature.length) return false;
+ for (let i = 0; i < signature.length; i++) {
+ if (buffer[offset + i] !== signature[i]) return false;
+ }
+ return true;
+}
+
+/**
+ * Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere
+ * (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`.
+ * Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild.
+ */
+export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null {
+ if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png';
+ if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg';
+ if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif';
+ if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp';
+ return null;
+}
diff --git a/apps/api/src/dashboard/dashboard-images.controller.spec.ts b/apps/api/src/dashboard/dashboard-images.controller.spec.ts
new file mode 100644
index 0000000..95346ea
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-images.controller.spec.ts
@@ -0,0 +1,122 @@
+import 'reflect-metadata';
+import { describe, expect, it, vi } from 'vitest';
+
+/**
+ * `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je
+ * Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst
+ * ihre Optionen in einer Klasse ohne lesbare Metadaten.
+ */
+const { fileInterceptorMock } = vi.hoisted(() => ({
+ fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
+}));
+vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
+
+import { ROLES_KEY } from '../auth/decorators/roles.decorator';
+import type { AuthUser } from '../auth/types/auth-user';
+import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
+import { DashboardImagesController } from './dashboard-images.controller';
+
+/**
+ * dashboard-images.controller.spec — NEU (quick-260921-pi9).
+ *
+ * Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge
+ * (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld
+ * `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten
+ * Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein
+ * `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich
+ * aus dem Sitzungsnachweis (T-PI9-11).
+ */
+const user: AuthUser = {
+ id: 'user-1',
+ username: 'anna',
+ role: 'USER',
+ tenantId: 'tenant-1',
+ mustChangePassword: false,
+};
+
+function makeService() {
+ return {
+ list: vi.fn(async () => []),
+ upload: vi.fn(async () => ({ id: 'new' })),
+ getBytes: vi.fn(async () => ({
+ mimeType: 'image/webp',
+ data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]),
+ })),
+ remove: vi.fn(async (id: string) => ({ id })),
+ };
+}
+
+function makeRes() {
+ const headers: Record = {};
+ return {
+ headers,
+ setHeader: vi.fn((name: string, value: string) => {
+ headers[name] = value;
+ }),
+ send: vi.fn(),
+ };
+}
+
+// Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht
+// beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf.
+describe('DashboardImagesController (quick-260921-pi9)', () => {
+ it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => {
+ expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images');
+ const proto = DashboardImagesController.prototype;
+ const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor');
+ expect(order).toEqual(['list', 'upload', 'getImage', 'remove']);
+ expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id');
+ expect(Reflect.getMetadata('path', proto.remove)).toBe(':id');
+ expect(Reflect.getMetadata('path', proto.list)).toBe('/');
+ expect(Reflect.getMetadata('path', proto.upload)).toBe('/');
+ });
+
+ it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => {
+ expect(fileInterceptorMock).toHaveBeenCalledTimes(1);
+ expect(fileInterceptorMock.mock.calls[0]).toEqual([
+ 'image',
+ { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } },
+ ]);
+ expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
+ });
+
+ it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => {
+ const service = makeService();
+ const controller = new DashboardImagesController(service as never);
+ const res = makeRes();
+
+ await controller.getImage('img-1', user, res as never);
+
+ expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1');
+ expect(res.headers['Content-Type']).toBe('image/webp');
+ expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
+ expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
+ expect(res.headers['Content-Disposition']).toBe('inline');
+ expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
+ const sent = res.send.mock.calls[0][0] as unknown;
+ expect(Buffer.isBuffer(sent)).toBe(true);
+ expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true);
+ });
+
+ it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => {
+ const proto = DashboardImagesController.prototype;
+ for (const name of ['list', 'upload', 'getImage', 'remove'] as const) {
+ expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
+ }
+ });
+
+ it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => {
+ const service = makeService();
+ const controller = new DashboardImagesController(service as never);
+ const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 };
+
+ await controller.list(user);
+ expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1');
+
+ await controller.upload(user, file);
+ expect(service.upload).toHaveBeenCalledWith(user, file);
+
+ await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' });
+ expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1');
+ });
+});
diff --git a/apps/api/src/dashboard/dashboard-images.controller.ts b/apps/api/src/dashboard/dashboard-images.controller.ts
new file mode 100644
index 0000000..92c109f
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-images.controller.ts
@@ -0,0 +1,87 @@
+import {
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Res,
+ UploadedFile,
+ UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { Response } from 'express';
+import { CurrentUser } from '../auth/decorators/current-user.decorator';
+import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
+import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
+import { DashboardImagesService } from './dashboard-images.service';
+
+/**
+ * Bilder des Bilderrahmen-Widgets (quick-260921-pi9).
+ *
+ * Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
+ * (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt
+ * weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem
+ * Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der
+ * Multipart-Rumpf hat genau das Feld `image`.
+ *
+ * Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine
+ * Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die
+ * deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status).
+ * `main.ts` bleibt ohne globales Body-Limit.
+ *
+ * Routen (statische VOR `:id`, sonst 404-Shadowing):
+ * - GET /dashboard/images — eigene Bilder, nur Metadaten
+ * - POST /dashboard/images — Bild hochladen (multipart-Feld `image`)
+ * - GET /dashboard/images/:id — Bytes eines eigenen Bildes
+ * - DELETE /dashboard/images/:id — eigenes Bild loeschen
+ */
+@Controller('dashboard/images')
+export class DashboardImagesController {
+ constructor(private readonly service: DashboardImagesService) {}
+
+ @Get()
+ async list(@CurrentUser() user: AuthUser) {
+ return this.service.list(user.id, user.tenantId);
+ }
+
+ @Post()
+ @UseInterceptors(
+ FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }),
+ )
+ async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) {
+ return this.service.upload(user, file);
+ }
+
+ /**
+ * Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der
+ * gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das
+ * Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen
+ * (`originalName` gehoert in keinen Header), `private` haelt gemeinsame
+ * Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im
+ * Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster
+ * favorites.controller.ts `getIcon`).
+ *
+ * Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber
+ * denselben Speicher, keine Kopie, keine Zusicherung.
+ */
+ @Get(':id')
+ async getImage(
+ @Param('id') id: string,
+ @CurrentUser() user: AuthUser,
+ @Res() res: Response,
+ ) {
+ const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId);
+
+ res.setHeader('Content-Type', mimeType);
+ res.setHeader('Cache-Control', 'private, max-age=86400');
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Disposition', 'inline');
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
+ res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
+ }
+
+ @Delete(':id')
+ async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
+ return this.service.remove(id, user.id, user.tenantId);
+ }
+}
diff --git a/apps/api/src/dashboard/dashboard-images.service.spec.ts b/apps/api/src/dashboard/dashboard-images.service.spec.ts
new file mode 100644
index 0000000..4ef1101
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-images.service.spec.ts
@@ -0,0 +1,291 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
+ * (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
+ * unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
+ * welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
+ * damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
+ */
+vi.mock('../prisma/prisma-tenant.extension', () => ({
+ forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
+ prisma.__makeBoundClient(tenantId, userId),
+ ),
+}));
+
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
+import { forTenant } from '../prisma/prisma-tenant.extension';
+import { DashboardImagesService } from './dashboard-images.service';
+
+/**
+ * dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
+ *
+ * Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload
+ * ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE
+ * Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND
+ * fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes,
+ * Loeschen eigen/fremd, und der Nachweis, dass jede Methode
+ * `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
+ * Argument aufruft.
+ */
+
+interface ImageRow {
+ id: string;
+ userId: string;
+ tenantId: string;
+ originalName: string;
+ mimeType: string;
+ size: number;
+ data: Uint8Array;
+ createdAt: Date;
+}
+
+interface BoundCall {
+ tenantId: string;
+ userId: string | undefined;
+ model: string;
+ method: string;
+}
+
+type ModelMethods = Record Promise>;
+
+interface FakePrisma {
+ dashboardImage: ModelMethods;
+ __rows: ImageRow[];
+ __boundCallLog: BoundCall[];
+ __makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
+}
+
+const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
+const TEXT = Buffer.from('nur Text, kein Bild');
+
+function makeRow(overrides: Partial = {}): ImageRow {
+ return {
+ id: overrides.id ?? 'img-1',
+ userId: overrides.userId ?? 'user-1',
+ tenantId: overrides.tenantId ?? 'tenant-1',
+ originalName: overrides.originalName ?? 'foto.png',
+ mimeType: overrides.mimeType ?? 'image/png',
+ size: overrides.size ?? PNG.length,
+ data: overrides.data ?? Uint8Array.from(PNG),
+ createdAt: overrides.createdAt ?? new Date('2026-01-01'),
+ };
+}
+
+function pick(row: ImageRow, select: Record | undefined) {
+ if (!select) return row;
+ const out: Record = {};
+ for (const key of Object.keys(select)) {
+ if (select[key]) out[key] = row[key as keyof ImageRow];
+ }
+ return out;
+}
+
+function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
+ const boundCallLog: BoundCall[] = [];
+ const dashboardImage: ModelMethods = {
+ findMany: vi.fn(async (raw: unknown) => {
+ const args = raw as { where: { tenantId: string; userId: string }; select?: Record };
+ return rows
+ .filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
+ .slice()
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
+ .map((r) => pick(r, args.select));
+ }),
+ count: vi.fn(async (raw: unknown) => {
+ const args = raw as { where: { tenantId: string; userId: string } };
+ return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
+ }),
+ create: vi.fn(async (raw: unknown) => {
+ const args = raw as { data: Omit; select?: Record };
+ const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
+ rows.push(created);
+ return pick(created, args.select);
+ }),
+ findUnique: vi.fn(async (raw: unknown) => {
+ const args = raw as { where: { id: string } };
+ return rows.find((r) => r.id === args.where.id) ?? null;
+ }),
+ delete: vi.fn(async (raw: unknown) => {
+ const args = raw as { where: { id: string } };
+ const idx = rows.findIndex((r) => r.id === args.where.id);
+ if (idx === -1) return null;
+ const [removed] = rows.splice(idx, 1);
+ return removed;
+ }),
+ };
+
+ const fake: FakePrisma = {
+ dashboardImage,
+ __rows: rows,
+ __boundCallLog: boundCallLog,
+ __makeBoundClient(tenantId: string, userId?: string) {
+ const wrapped: ModelMethods = {};
+ for (const method of Object.keys(dashboardImage)) {
+ wrapped[method] = async (...args: unknown[]) => {
+ boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
+ return dashboardImage[method](...args);
+ };
+ }
+ return { dashboardImage: wrapped };
+ },
+ };
+ return fake;
+}
+
+function makeService(prisma: FakePrisma) {
+ // Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das
+ // Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten).
+ // `as never` statt einer Doppelzusicherung ueber unknown (Muster
+ // tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27).
+ return new DashboardImagesService(prisma as never);
+}
+
+const user: AuthUser = {
+ id: 'user-1',
+ username: 'anna',
+ role: 'USER',
+ tenantId: 'tenant-1',
+ mustChangePassword: false,
+};
+
+function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike {
+ return { buffer, mimetype, originalname, size: buffer.length };
+}
+
+beforeEach(() => {
+ vi.mocked(forTenant).mockClear();
+});
+
+describe('DashboardImagesService (quick-260921-pi9)', () => {
+ it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => {
+ const prisma = makeFakePrisma([
+ makeRow({ id: 'b', createdAt: new Date('2026-03-01') }),
+ makeRow({ id: 'a', createdAt: new Date('2026-01-01') }),
+ makeRow({ id: 'fremd', userId: 'user-2' }),
+ ]);
+ const result = await makeService(prisma).list('user-1', 'tenant-1');
+ expect(result.map((r) => r.id)).toEqual(['a', 'b']);
+ for (const r of result) {
+ expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
+ }
+ const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record };
+ expect(call.select.data).toBeUndefined();
+ });
+
+ it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
+ const prisma = makeFakePrisma();
+ await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow(
+ new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'),
+ );
+ expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
+ });
+
+ it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => {
+ const prisma = makeFakePrisma();
+ const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt'));
+ expect(result.mimeType).toBe('image/png');
+ expect(result.originalName).toBe('irgendwas.txt');
+ expect(result.size).toBe(PNG.length);
+ expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
+ expect(prisma.__rows[0].userId).toBe('user-1');
+ expect(prisma.__rows[0].tenantId).toBe('tenant-1');
+ });
+
+ it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
+ const prisma = makeFakePrisma();
+ await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
+ new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
+ );
+ expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
+ });
+
+ it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => {
+ const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` }));
+ const full = makeFakePrisma(thirty);
+ await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
+ new BadRequestException(
+ 'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.',
+ ),
+ );
+ expect(full.dashboardImage.create).not.toHaveBeenCalled();
+
+ const almost = makeFakePrisma(thirty.slice(0, 29));
+ await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
+ mimeType: 'image/png',
+ });
+ });
+
+ it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
+ const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
+ const prisma = makeFakePrisma(foreign);
+ await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
+ mimeType: 'image/png',
+ });
+ const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown };
+ expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' });
+ });
+
+ it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => {
+ const prisma = makeFakePrisma();
+ const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400)));
+ expect(result.originalName).toHaveLength(255);
+ });
+
+ it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
+ const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]);
+ await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
+ });
+
+ it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
+ const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]);
+ const service = makeService(prisma);
+ await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
+ await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
+ });
+
+ it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
+ const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
+ const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
+ expect(result.mimeType).toBe('image/png');
+ expect(Buffer.from(result.data).equals(PNG)).toBe(true);
+ });
+
+ it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
+ const prisma = makeFakePrisma([
+ makeRow({ id: 'eigen' }),
+ makeRow({ id: 'fremd-user', userId: 'user-2' }),
+ makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
+ ]);
+ const service = makeService(prisma);
+ await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
+ expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
+ await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
+ await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
+ expect(prisma.__rows).toHaveLength(2);
+ });
+
+ it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
+ const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
+ const service = makeService(prisma);
+ await service.list('user-1', 'tenant-1');
+ await service.upload(user, file(PNG, 'image/png'));
+ await service.getBytes('img-1', 'user-1', 'tenant-1');
+ await service.remove('img-1', 'user-1', 'tenant-1');
+
+ expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
+ for (const call of vi.mocked(forTenant).mock.calls) {
+ expect(call[0]).toBe(prisma);
+ expect(call[1]).toBe('tenant-1');
+ expect(call[2]).toBe('user-1');
+ }
+ // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
+ const methods = prisma.__boundCallLog.map((c) => c.method);
+ expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
+ for (const c of prisma.__boundCallLog) {
+ expect(c.tenantId).toBe('tenant-1');
+ expect(c.userId).toBe('user-1');
+ }
+ });
+});
diff --git a/apps/api/src/dashboard/dashboard-images.service.ts b/apps/api/src/dashboard/dashboard-images.service.ts
new file mode 100644
index 0000000..2cfa2fc
--- /dev/null
+++ b/apps/api/src/dashboard/dashboard-images.service.ts
@@ -0,0 +1,148 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
+import { forTenant } from '../prisma/prisma-tenant.extension';
+import { PrismaService } from '../prisma/prisma.service';
+import {
+ DASHBOARD_IMAGE_MAX_COUNT,
+ type DashboardImageMime,
+ detectImageMime,
+} from './dashboard-image-rules';
+
+/**
+ * DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
+ * (quick-260921-pi9).
+ *
+ * Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND
+ * gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen,
+ * `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404)
+ * ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration
+ * 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als
+ * Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS
+ * (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich
+ * hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die
+ * `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie
+ * ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem
+ * Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null`
+ * — die Antwort bleibt 404, nur der Weg dorthin aendert sich.
+ *
+ * Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
+ * Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
+ *
+ * Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
+ * `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
+ * spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
+ * `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
+ * Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
+ * einem HTTP-Header (T-PI9-06).
+ *
+ * Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
+ * Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
+ * um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es
+ * betrifft nur den eigenen Speicher.
+ *
+ * Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des
+ * Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des
+ * Benutzers laedt sie selbst.
+ */
+
+const ORIGINAL_NAME_MAX = 255;
+
+/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
+const META_SELECT = {
+ id: true,
+ originalName: true,
+ mimeType: true,
+ size: true,
+ createdAt: true,
+} as const;
+
+export interface DashboardImageMeta {
+ id: string;
+ originalName: string;
+ mimeType: string;
+ size: number;
+ createdAt: Date;
+}
+
+@Injectable()
+export class DashboardImagesService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ /** Eigene Bilder, aelteste zuerst, nur Metadaten. */
+ async list(userId: string, tenantId: string): Promise {
+ const tenantPrisma = forTenant(this.prisma, tenantId, userId);
+ return tenantPrisma.dashboardImage.findMany({
+ where: { tenantId, userId },
+ select: META_SELECT,
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
+ /**
+ * Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
+ * begrenzt, gespeichert wird der erkannte Typ.
+ */
+ async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise {
+ if (!file) {
+ throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
+ }
+
+ const mimeType: DashboardImageMime | null = detectImageMime(file.buffer);
+ if (mimeType === null) {
+ throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.');
+ }
+
+ const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id);
+ const existing = await tenantPrisma.dashboardImage.count({
+ where: { tenantId: user.tenantId, userId: user.id },
+ });
+ if (existing >= DASHBOARD_IMAGE_MAX_COUNT) {
+ throw new BadRequestException(
+ `Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`,
+ );
+ }
+
+ return tenantPrisma.dashboardImage.create({
+ data: {
+ userId: user.id,
+ tenantId: user.tenantId,
+ originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
+ mimeType,
+ size: file.buffer.length,
+ // Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
+ // `Uint8Array`, multers `Buffer` ist aber ueber
+ // `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
+ // wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
+ // einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
+ // und ist damit ehrlich getypt statt zugesichert.
+ data: new Uint8Array(file.buffer),
+ },
+ select: META_SELECT,
+ });
+ }
+
+ /** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
+ async getBytes(
+ id: string,
+ userId: string,
+ tenantId: string,
+ ): Promise<{ mimeType: string; data: Uint8Array }> {
+ const tenantPrisma = forTenant(this.prisma, tenantId, userId);
+ const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
+ if (!row || row.userId !== userId || row.tenantId !== tenantId) {
+ throw new NotFoundException(`Image with id '${id}' not found`);
+ }
+ return { mimeType: row.mimeType, data: row.data };
+ }
+
+ /** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
+ async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
+ const tenantPrisma = forTenant(this.prisma, tenantId, userId);
+ const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
+ if (!row || row.userId !== userId || row.tenantId !== tenantId) {
+ throw new NotFoundException(`Image with id '${id}' not found`);
+ }
+ await tenantPrisma.dashboardImage.delete({ where: { id } });
+ return { id };
+ }
+}
diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts
index dcee374..4e45902 100644
--- a/apps/api/src/dashboard/dashboard.module.ts
+++ b/apps/api/src/dashboard/dashboard.module.ts
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { DashboardController } from './dashboard.controller';
+import { DashboardImagesController } from './dashboard-images.controller';
+import { DashboardImagesService } from './dashboard-images.service';
import { DashboardService } from './dashboard.service';
/**
@@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service';
* Provides:
* - DashboardService: CRUD for per-user dashboard layouts and widget instances
* - DashboardController: REST API for layout and widget operations
+ * - DashboardImagesService/-Controller: Bilder des Bilderrahmen-Widgets
+ * (quick-260921-pi9), je Benutzer in der Datenbank
*
* Imports ModuleRegistryModule so DashboardService can inject
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
@@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service';
*/
@Module({
imports: [ModuleRegistryModule],
- controllers: [DashboardController],
- providers: [DashboardService],
+ controllers: [DashboardController, DashboardImagesController],
+ providers: [DashboardService, DashboardImagesService],
exports: [DashboardService],
})
export class DashboardModule {}
diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts
index f12884e..36640e8 100644
--- a/apps/api/src/dashboard/dto/create-widget.dto.ts
+++ b/apps/api/src/dashboard/dto/create-widget.dto.ts
@@ -2,12 +2,22 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
/**
* DTO for creating a new widget instance on a user's dashboard.
- * widgetType must be one of the seven supported types.
+ * widgetType must be one of the eight supported types
+ * ('picture-frame' seit quick-260921-pi9).
* config is optional and defaults to {} on the model.
*/
export class CreateWidgetDto {
@IsString()
- @IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
+ @IsIn([
+ 'clock',
+ 'search',
+ 'calendar',
+ 'note',
+ 'calculator',
+ 'favorites',
+ 'stopwatch',
+ 'picture-frame',
+ ])
widgetType!: string;
@IsOptional()
diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md
index 24cba5e..51bfa27 100644
--- a/docs/mandantentrennung-zugriffsklassifikation.md
+++ b/docs/mandantentrennung-zugriffsklassifikation.md
@@ -168,16 +168,16 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
-| dashboard | 1 | 12 | 0 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
+| dashboard | 1 | 18 | 0 | **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
-| settings | 0 | 3 | 0 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
-| **Summe** | **61** | **179** | **5** | **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
+| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
+| **Summe** | **61** | **187** | **5** | **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
-## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 72 Paare)
+## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
@@ -191,6 +191,16 @@ Die Zahl ist der Ausgabe der Pruefung in
`apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht
geschaetzt.
+**Nachtrag 260921-pi9:** 74 Paare — ein neues Paar
+`dashboard/dashboard-images.service.ts`/`dashboardImage`
+(muss-mandantengebunden, gebunden) fuer die hochgeladenen Bilder des
+Bilderrahmen-Widgets. Nachgezaehlt mit `grep -cE` ueber die Fundstellen-
+tabelle: vor diesem Eintrag standen dort bereits 73 Zeilen, nicht 72 — das
+Paar `bug-reports/bug-reports.service.ts`/`user` (260914-m97,
+muss-mandantengebunden) war in der Tabelle eingetragen, in dieser
+Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind
+gemessen, nicht geschaetzt.
+
**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
@@ -319,11 +329,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
| Klasse | Anzahl Paare |
|---|---|
-| muss-mandantengebunden | 35 |
+| muss-mandantengebunden | 37 |
| keine-mandantengebundene-tabelle | 21 |
| beides | 14 |
| bewusst-uebergreifend | 2 |
-| **Summe** | **72** |
+| **Summe** | **74** |
## Der Hintergrunddienst als Falle — sechs Fälle
@@ -663,6 +673,7 @@ werden.
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
+| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |