diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index f2573d8..fe2e9fa 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -609,3 +609,39 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 14. RSS, Email-Alert Ingestion & Module Rollout | 5/5 | In Progress| |
| 15. Modul-Berechtigungen: Gruppen & User-Grants | 8/8 | Complete | 2026-08-04 (Bericht 15-04 am 2026-08-11 nachgezogen) |
| 16. AD-Gruppen-Synchronisation | 5/5 | Complete | 2026-08-11 |
+| 17. Eigene Ausschreibungs-Quellen je Nutzer | 0/3 | Planned | - |
+
+### Phase 17: Eigene Ausschreibungs-Quellen je Nutzer
+
+**Goal:** Jeder Nutzer speist seine eigenen Ausschreibungs-Quellen ein — eigenes Alert-Postfach und eigene RSS-Feeds statt einer gemeinsamen Konfiguration. Die Einstellungsseite trennt danach sauber nach Zustaendigkeit: Plattform-Administration (Abrufintervall der oeffentlichen Quelle) bleibt Admin-Sache, Quellen und Digest-Intervall gehoeren dem Nutzer.
+
+**Ausdruecklich NICHT in dieser Phase:** Ausschreibungsdaten bleiben plattform-global (Entscheidung D-03 aus Phase 10). Geaendert wird, WER Quellen einspeist — nicht, wer Treffer sieht. Was ueber das Postfach von Nutzer A hereinkommt, steht danach weiterhin auch in der Trefferliste von Nutzer B. Eine Sichtbarkeitstrennung waere ein deutlich groesserer Umbau und ist hier bewusst ausgeklammert.
+
+**Ausloeser:** Backlog-Punkt `2026-08-11-tender-radar-einstellungen-mischen-rollen.md`. Beim Durchsprechen am 2026-08-11 kam heraus, dass die urspruengliche Zustimmung zur gemeinsamen Konfiguration auf einer missverstaendlichen Erklaerung meinerseits beruhte — zwei Nutzer mit je eigenem Portal-Konto koennen ihre Alerts heute gar nicht beide anbinden.
+
+**Requirements**: SRC-01, SRC-02, SRC-03, SRC-04, SRC-05 — beim Planen am 2026-08-12 aus dem Backlog-Punkt und 17-CONTEXT.md abgeleitet, neue modul-lokale Kategorie SRC (Quellen-Besitz):
+
+- **SRC-01**: Jeder Nutzer bindet sein eigenes Alert-Postfach an; ein Mandant kann mehrere Postfaecher haben (D-01).
+- **SRC-02**: RSS-Feeds haben einen Besitzer; Feeds ohne Besitzer bleiben plattformweit und von der Administration gepflegt (D-02).
+- **SRC-03**: Der zeitgesteuerte Abruf holt weiterhin alle Quellen in einem Durchlauf; eine kaputte Quelle blockiert die anderen nicht.
+- **SRC-04**: Nutzereigene Einstellungen (Postfach, eigene Feeds, Digest-Intervall) liegen auf einer eigenen, fuer jeden Modulnutzer erreichbaren Seite (D-04).
+- **SRC-05**: Die verbleibenden Administrationseinstellungen sind fuer normale Nutzer nicht sichtbar; die Rollenpruefung greift in der Oberflaeche wie in der API (D-03).
+
+**Depends on:** Phase 16
+**Plans:** 3 plans
+
+**Success Criteria** (what must be TRUE):
+
+ 1. Zwei Nutzer desselben Mandanten koennen gleichzeitig je ein eigenes Alert-Postfach anbinden; keiner sieht oder ueberschreibt das des anderen
+ 2. Ein Nutzer legt eigene RSS-Feeds an und sieht in seiner Liste nur die eigenen plus die plattformweiten; der seit Phase 14 gesetzte service.bund.de-Feed bleibt fuer alle unveraendert aktiv
+ 3. Kein Nutzer kann einen fremden oder plattformweiten Feed loeschen oder einen plattformweiten Feed anlegen — beides bleibt der Administration vorbehalten
+ 4. Der Abruf holt alle Postfaecher und Feeds in einem Durchlauf; faellt eine Quelle aus, laufen die uebrigen weiter
+ 5. Auf `/modules/tender-radar/my-sources` findet jeder Nutzer Postfach, eigene Feeds und Benachrichtigungsintervall an einer Stelle
+ 6. Auf `/modules/tender-radar/settings` bleibt nur Abrufintervall und plattformweite Feed-Pflege; ein normaler Nutzer sieht dort keine Bedienelemente mehr, die beim Speichern in eine Fehlermeldung laufen
+ 7. `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05 / Phase-10-D-03 unangetastet)
+
+Plans (Wellenstruktur — streng nacheinander, alle drei fassen Schema, Controller oder die Zugriffsschicht gemeinsam an):
+
+- [ ] 17-01-PLAN.md (Welle 1) — Alert-Postfach wechselt vom Mandanten zum Nutzer: Schema, handgeschriebene Migration mit Besitzer-Zuordnung, Dienst und Endpunkt, erste Fassung der Seite "Meine Quellen". Enthaelt den Entscheidungspunkt fuer beide Datenbank-Umbauten der Phase.
+- [ ] 17-02-PLAN.md (Welle 2, nach 17-01) — RSS-Feeds bekommen einen Besitzer: Schema und Migration, Besitzerlogik, Schutz gegen fremdes Loeschen, Mengenbegrenzung, Herkunftsmarkierung im Abruf, angepasste Startbestueckung.
+- [ ] 17-03-PLAN.md (Welle 3, nach 17-01 und 17-02) — Oberflaeche nach Zustaendigkeit trennen: "Meine Quellen" vollstaendig, Administrationsseite reduziert und rollengeprueft, Zahnrad umgehaengt, Beschriftungen in beiden Sprachen, Backlog-Punkt geschlossen.
diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md
new file mode 100644
index 0000000..42c42c7
--- /dev/null
+++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md
@@ -0,0 +1,398 @@
+---
+phase: 17-eigene-ausschreibungs-quellen-je-nutzer
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - apps/api/prisma/schema.prisma
+ - apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql
+ - apps/api/src/tenders/tender-email-config.service.ts
+ - apps/api/src/tenders/tenders.controller.ts
+ - apps/api/src/tenders/adapters/email-alert.adapter.ts
+ - apps/api/src/tenders/tender-email-config.service.spec.ts
+ - apps/api/src/tenders/adapters/email-alert.adapter.spec.ts
+ - apps/api/src/tenders/email-config-migration-sql.spec.ts
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx
+autonomous: false
+requirements: [SRC-01, SRC-03]
+user_setup: []
+
+estimate:
+ tokens: 60000
+ raw_tokens: 60000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Zwei verschiedene Nutzer desselben Mandanten koennen gleichzeitig je ein eigenes Alert-Postfach hinterlegen (D-01)."
+ - "Beim Abruf werden beide Postfaecher in einem Durchlauf geleert; faellt eines aus, laufen die anderen weiter (D-01, offener Punkt 3)."
+ - "Was aus dem Postfach eines Nutzers hereinkommt, bleibt weiterhin auf dessen Mandanten begrenzt — die in Phase 14 gebaute Herkunftsmarkierung bleibt unveraendert (D-05)."
+ - "Eine bereits vorhandene Postfach-Zeile geht bei der Umstellung nicht verloren, sondern bekommt einen Besitzer (offener Punkt 1)."
+ - "Die Seite `/modules/tender-radar/my-sources` ist fuer jeden Nutzer mit Modulzugang erreichbar und zeigt sein eigenes Postfach."
+ artifacts:
+ - apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql
+ - apps/api/src/tenders/email-config-migration-sql.spec.ts
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx
+ key_links:
+ - "TenderEmailConfig.userId <-> extractTriageContext(req).userId — Besitz kommt ausschliesslich aus dem angemeldeten Konto, nie aus dem Request-Body (IDOR)."
+ - "TenderEmailConfig.tenantId <-> EmailAlertAdapter.extractCandidates(..., cfg.tenantId, ...) — die Herkunftsmarkierung der eingelesenen Ausschreibungen haengt weiter an diesem Feld."
+ - "GET/PUT /modules/tender-radar/email-config <-> Reihenfolge vor `@Get(':id')` — statische Route darf nicht von der Parameter-Route verdeckt werden."
+---
+
+
+Das Alert-Postfach wechselt vom Mandanten zum einzelnen Nutzer. Heute gibt es
+genau ein Postfach pro Mandant (`TenderEmailConfig.tenantId @unique`) — ein
+zweiter Kollege mit eigenem Portal-Konto kann seine Ausschreibungs-Alarme
+schlicht nicht anbinden. Nach diesem Plan hinterlegt jeder Nutzer sein eigenes
+Postfach, und der Abruf holt alle hinterlegten Postfaecher in einem Durchlauf ab.
+
+Dieser Plan ist die duenne, aber vollstaendige Bahn durch alle Schichten:
+Datenbank -> Migration -> Dienst -> Endpunkt -> eine eigene, fuer jeden
+Modulnutzer erreichbare Seite. Erst wenn diese eine Bahn nachweislich laeuft,
+bauen die Plaene 17-02 (RSS-Feeds) und 17-03 (Oberflaeche aufteilen) daneben
+weiter.
+
+Purpose: D-01 aus 17-CONTEXT.md umsetzen und damit die eigentliche Ursache des
+Backlog-Punkts beheben — nicht nur die Anzeige, sondern den Zuschnitt.
+Output: Geaenderte Tabelle samt Umzug der Bestandsdaten, nutzerbezogener Dienst
+und Endpunkt, neue Seite "Meine Quellen" mit dem Postfach-Formular.
+
+**Harte Grenze (D-05):** Ausschreibungsdaten bleiben plattform-global. `Tender`
+bekommt kein Mandantenfeld, keine RLS, keinen Mandanten-Wrapper. Geaendert wird
+ausschliesslich, wer Quellen einspeist — nicht, wer Treffer sieht. Innerhalb
+eines Mandanten sieht weiterhin jeder alles.
+
+**Entscheidung zum offenen Punkt 1 (Besitzer der Bestandszeile):** Eine
+vorhandene Postfach-Zeile wird dem aeltesten aktiven ADMIN bzw. SUPER_ADMIN
+ihres Mandanten zugeordnet, nicht geloescht. Begruendung: Die Zeile wurde
+seinerzeit von genau dieser Rolle angelegt, sie enthaelt verschluesselte
+Zugangsdaten, und ein Loeschen wuerde den laufenden Abruf ohne Vorwarnung
+stilllegen. Nur falls ein Mandant ueberhaupt keinen aktiven Administrator hat,
+wird die Zeile entfernt — dann gaebe es niemanden, der sie je wieder bearbeiten
+koennte, waehrend das Postfach im Hintergrund weiter abgefragt wuerde.
+
+**Entscheidung zum offenen Punkt 4 (wohin mit den nutzereigenen Abschnitten):**
+Es entsteht eine eigene nutzerseitige Modulseite `/modules/tender-radar/my-sources`
+("Meine Quellen"), getrennt von der Administrationsseite. Begruendung: Die
+Alternative, alles nach `/settings/general` zu verschieben, sammelt dort mit
+jedem neuen Modul modulfremde Einstellungen an — genau der Einwand aus dem
+Backlog-Punkt. Modulsachen bleiben beim Modul; DKV-Fleet und kuenftige Module
+koennen dieselbe Bauform uebernehmen. Der Adresspfad bleibt englisch
+(`my-sources`), wie jeder andere Pfad im Projekt auch; die Beschriftung ist
+deutsch.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md
+
+@apps/api/prisma/schema.prisma
+@apps/api/src/tenders/tender-email-config.service.ts
+@apps/api/src/tenders/adapters/email-alert.adapter.ts
+@apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql
+@apps/api/src/tenders/doe-url-migration-sql.spec.ts
+
+
+
+
+
+ Task 1: Freigabe fuer die beiden Datenbank-Umbauten dieser Phase
+ Darf ich die Tabellenstruktur fuer Postfaecher und RSS-Feeds jetzt umbauen?
+
+Diese Phase aendert zwei Tabellen dauerhaft. Beides laesst sich nur mit einer
+weiteren Datenbank-Aenderung zurueckdrehen, nicht durch einfaches Rueckgaengigmachen
+der Dateien:
+
+1. **Postfaecher** (dieser Plan): Das Postfach haengt danach am Nutzer statt am
+ Mandanten. Eine bereits eingerichtete Postfach-Zeile bekommt dabei den
+ aeltesten aktiven Administrator ihres Mandanten als Besitzer. Findet sich dort
+ kein Administrator, wird die Zeile entfernt — sie waere sonst fuer niemanden
+ mehr bearbeitbar, wuerde aber weiter abgefragt.
+2. **RSS-Feeds** (Plan 17-02): Die Feeds bekommen ein Besitzer-Feld. Alle heute
+ vorhandenen Feeds bleiben unveraendert plattformweit fuer alle aktiv — dort
+ geht nichts verloren.
+
+Auf dem Testserver liegen echte Daten. Der Umbau selbst laeuft hier lokal; auf
+dem Testserver wird er erst wirksam, wenn Sie dort selbst neu ausrollen. Vor
+diesem Ausrollen ist eine Datenbank-Sicherung sinnvoll.
+
+Bevor ich anfange, zaehle ich nach, wie viele Postfach-Zeilen es auf dem
+Testserver ueberhaupt gibt, und nenne Ihnen die Zahl — nur lesend, ich fasse
+dort nichts an.
+
+
+
+
+
+ Antworten Sie mit "weiter" oder "stopp".
+
+
+
+ Task 2: Ein eigenes Postfach je Nutzer — durchgehend von der Datenbank bis zur Seite
+ Die lokale Entwicklungsdatenbank ist ohne Host-Port erreichbar: Container-IP des `db`-Containers ermitteln (`docker inspect`) und `DATABASE_URL` mit Zugang `tessera:tessera_dev` darauf zeigen lassen — sonst laesst sich die Migration nicht anwenden (Projektwissen "Lokale DB-Migrationen").
+ Die Migration schreibt Bestandsdaten um und entfernt eine Eindeutigkeitsregel; ein Rueckweg braucht eine zweite Migration. Freigabe erfolgt in Task 1.
+
+ apps/api/prisma/schema.prisma,
+ apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql,
+ apps/api/src/tenders/tender-email-config.service.ts,
+ apps/api/src/tenders/tenders.controller.ts,
+ apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx
+
+
+ apps/api/src/tenders/tender-email-config.service.ts,
+ apps/api/src/tenders/tenders.controller.ts (Zeilen 90-115 und 270-305),
+ apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
+
+
+ - Ein Nutzer speichert sein Postfach; danach liest derselbe Nutzer genau diese Werte zurueck.
+ - Ein zweiter Nutzer desselben Mandanten speichert ein anderes Postfach; beide Zeilen existieren nebeneinander, keine ueberschreibt die andere.
+ - Nutzer B liest nie die Werte von Nutzer A — gelesen wird ausschliesslich nach dem angemeldeten Konto.
+ - Das Passwort verlaesst den Server nie: die Antwort enthaelt weiterhin nur `hasPassword: boolean`.
+ - Wird nur der Benutzername geaendert und das Passwortfeld leer gelassen, bleibt das gespeicherte Passwort erhalten (bestehende Semantik, unveraendert).
+
+
+**Vorabpruefung (nur lesend, Testserver).** Ueber SSH auf 192.168.13.12 mit
+`psql` zaehlen, wie viele Zeilen in `TenderEmailConfig` stehen und zu welchem
+Mandanten sie gehoeren. Ergebnis im SUMMARY festhalten. Nichts aendern, kein
+`docker compose`-Kommando dort ausfuehren — Ausrollen ist Sache des Nutzers.
+
+**Schema (`apps/api/prisma/schema.prisma`, Modell `TenderEmailConfig`).**
+Feld `userId String @unique` ergaenzen. `tenantId` von `@unique` auf ein
+gewoehnliches Feld zurueckstufen (bleibt erhalten, denormalisiert, fuer die
+SMTP-Aufloesung und die Herkunftsmarkierung — gleiche Rolle wie in
+`TenderMatch`, D-01). Bestehenden `@@index([tenantId])` behalten,
+`@@index([userId])` ergaenzen. Den Kopfkommentar des Modells um zwei Saetze
+erweitern: Besitz liegt beim Nutzer (D-01, Phase 17), `tenantId` bleibt
+denormalisiert.
+
+**Migration.** Verzeichnis
+`apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/`
+anlegen, `migration.sql` von Hand schreiben (Projektentscheidung
+"Migrationsverfahren angepasst": `prisma migrate dev` verweigert die
+nicht-interaktive Shell). Erwarteter Ablauf, in dieser Reihenfolge:
+
+1. Spalte `userId` als `TEXT` ohne Pflicht ergaenzen.
+2. Bestandszeilen befuellen: je Zeile den aeltesten aktiven Nutzer mit Rolle
+ `ADMIN` oder `SUPER_ADMIN` desselben Mandanten ermitteln (sortiert nach
+ `createdAt`, bei Gleichstand nach `id`, `LIMIT 1`) und dessen `id` eintragen.
+3. Zeilen, die danach immer noch keinen Besitzer haben, entfernen (kein
+ Administrator im Mandanten vorhanden — siehe Begruendung im Objective).
+4. Eindeutigkeitsregel auf `tenantId` entfernen
+ (`TenderEmailConfig_tenantId_key`), gewoehnlichen Index `..._tenantId_idx`
+ stehen lassen.
+5. Spalte `userId` auf Pflicht setzen, eindeutigen Index
+ `TenderEmailConfig_userId_key` und Index `TenderEmailConfig_userId_idx`
+ anlegen.
+
+Ueber die Anweisungen einen deutschen Kommentarkopf setzen, der erklaert, warum
+umgestellt wird und warum die Bestandszeile dem aeltesten Administrator
+zufaellt — gleiche Form wie
+`20260811140000_encrypt_ldap_bind_password/migration.sql`.
+
+Anwenden mit `prisma migrate deploy` gegen die lokale Datenbank, danach
+`prisma generate`.
+
+**Dienst (`tender-email-config.service.ts`).** `getConfigForApi` nimmt statt
+`tenantId` nun `userId` und sucht darueber. `saveConfig` nimmt ein Objekt
+`{ userId, tenantId }` als ersten Parameter; im `upsert` ist `userId` der
+Schluessel, `tenantId` wird sowohl beim Anlegen als auch beim Aktualisieren
+mitgeschrieben (der Mandant eines Nutzers kann sich aendern, das
+denormalisierte Feld muss mitziehen). `EMAIL_CONFIG_SAFE_SELECT` um `userId`
+ergaenzen; der verschluesselte Zugangsdaten-Block bleibt wie bisher aus jeder
+Antwort ausgeschlossen. Die Semantik "Passwort leer heisst gespeichertes
+Passwort behalten" bleibt unveraendert; nur die Suchbedingungen wechseln von
+`tenantId` auf `userId`. Klassenkommentar entsprechend nachziehen.
+
+**Endpunkt (`tenders.controller.ts`).** `GET`/`PUT /email-config`: die
+Rollenpruefung `@Roles(ADMIN, SUPER_ADMIN)` entfaellt und wird durch
+`@UseModule('tender-radar')` ersetzt — das Postfach ist ab jetzt eine
+Nutzereinstellung, der Zugang haengt an der Modulfreigabe aus Phase 15.
+`userId` und `tenantId` kommen weiterhin ausschliesslich aus
+`extractTriageContext(req)`, nie aus dem Rumpf der Anfrage. **Die Position
+beider Handler im Datei-Aufbau nicht verschieben**: sie stehen vor der
+Parameter-Route `@Get(':id')`, sonst verdeckt diese sie und die Endpunkte
+antworten mit 404 (dokumentierte Falle, Unit-Tests fangen sie nicht). Die
+vorhandenen Kommentare zu dieser Reihenfolge stehen lassen und den Hinweis auf
+die Mandantenbindung im Kommentar auf die Nutzerbindung umschreiben.
+
+**Seite (`apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx`).**
+Neue Client-Seite, gleiche Bauform wie die vorhandene Einstellungsseite:
+Ueberschrift aus dem i18n-Namensraum `tenderRadar` (neuer Schluessel
+`mySources.title`, deutsch "Meine Quellen", englisch "My sources"; beide
+Sprachdateien `apps/web/src/messages/de.json` und `en.json` ergaenzen),
+darunter ein Abschnitt "Mein Postfach" mit dem **unveraenderten** bestehenden
+`EmailAlertConfigForm` (nur importieren, nicht kopieren, nicht umbauen — es
+spricht dieselben Endpunkte an). Ein kurzer Hinweistext unter der Ueberschrift
+benennt ehrlich, was D-05 bedeutet: die hier eingespeisten Ausschreibungen
+erscheinen in der Trefferliste aller Kollegen desselben Mandanten. Keine
+Aenderung am Modul-Verzeichnis noetig — verschachtelte Route unterhalb der
+bereits freigeschalteten Modulseite, gleiche Begruendung wie bei der
+Einstellungsseite.
+
+
+ cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check
+ cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL'
+SELECT indexname FROM pg_indexes WHERE tablename = 'TenderEmailConfig' ORDER BY indexname;
+SQL
+ Im Browser als normaler Nutzer (Rolle USER mit Modulfreigabe) `/modules/tender-radar/my-sources` oeffnen: die Seite laedt, das Postfach-Formular ist bedienbar, Speichern quittiert erfolgreich und nach einem Neuladen stehen die Werte wieder da. Danach mit einem zweiten Konto desselben Mandanten anmelden: dort steht ein leeres Formular, nicht die Werte des ersten Nutzers.
+
+
+`prisma migrate status` meldet alle Migrationen angewandt. Die Indexliste zeigt
+`TenderEmailConfig_userId_key` und enthaelt `TenderEmailConfig_tenantId_key`
+nicht mehr. Beide Typpruefungen laufen fehlerfrei. Zwei Konten desselben
+Mandanten haben je eine eigene Postfach-Zeile.
+
+
+
+
+ Task 3: Der Abruf holt alle Postfaecher — und beweist es mit Tests
+
+ apps/api/src/tenders/adapters/email-alert.adapter.ts,
+ apps/api/src/tenders/adapters/email-alert.adapter.spec.ts,
+ apps/api/src/tenders/tender-email-config.service.spec.ts,
+ apps/api/src/tenders/email-config-migration-sql.spec.ts
+
+
+ apps/api/src/tenders/adapters/email-alert.adapter.spec.ts,
+ apps/api/src/tenders/tender-email-config.service.spec.ts,
+ apps/api/src/tenders/doe-url-migration-sql.spec.ts
+
+
+ - Zwei aktive Postfaecher **desselben** Mandanten werden in einem einzigen Abruf beide geleert. Das ist genau die Faehigkeit, die es vorher nicht gab — der Test faellt ohne Task 2 durch.
+ - Faellt eines von zwei Postfaechern mit einem Fehler aus, liefert der Abruf trotzdem die Datensaetze des anderen und protokolliert eine Warnung.
+ - Die eingelesenen Datensaetze tragen weiterhin die Herkunftsmarkierung aus dem Mandantenfeld der jeweiligen Postfach-Zeile (unveraenderte Sichtbarkeit gegenueber anderen Mandanten, D-05).
+ - Die Sammelabfrage liest weiterhin alle aktiven Postfaecher ueber Mandantengrenzen hinweg in einem Zug — der Plattform-Zeitplan ist keine Anfrage eines einzelnen Mandanten.
+ - Die Migrationsdatei ordnet Bestandszeilen einem Administrator zu, bevor sie unbesetzte Zeilen entfernt.
+
+
+**Anpassung im Abruf (`email-alert.adapter.ts`).** Inhaltlich aendert sich
+nichts an der Mechanik: die Sammelabfrage bleibt exakt wie sie ist (alle
+aktiven Zeilen in einem Zug), und die Herkunftsmarkierung der Datensaetze kommt
+weiterhin aus dem Mandantenfeld der jeweiligen Zeile — dieses Feld existiert
+nach Task 2 unveraendert. Zu aendern sind nur zwei Dinge: (a) die Warnmeldung
+im Fehlerfall benennt jetzt das Postfach ueber die Zeilen-`id` und die
+Besitzer-Kennung statt des Mandanten, weil ein Mandant ab sofort mehrere
+Postfaecher haben kann und die alte Meldung sonst mehrdeutig waere — Zugangsdaten
+duerfen dabei nach wie vor nirgends im Protokoll landen; (b) der
+Klassenkommentar erklaert, dass die Auffaecherung ab Phase 17 pro Postfach und
+nicht mehr pro Mandant laeuft. Die ausdrueckliche Warnung im Kommentar, dass
+diese Sammelabfrage niemals in einen Mandanten-Wrapper gehoert, bleibt Wort fuer
+Wort stehen. Die Fehlerbehandlung je Postfach (ein kaputtes Postfach blockiert
+die anderen nicht) bleibt unangetastet.
+
+**Tests `email-alert.adapter.spec.ts`.** Bestehende Faelle auf die neue
+Datenform nachziehen (Zeilen tragen jetzt zusaetzlich `userId`) und drei neue
+Faelle ergaenzen:
+1. Zwei aktive Zeilen mit **gleichem** Mandanten, aber verschiedenen Besitzern
+ und verschiedenen Postfachdaten: der Postfach-Anbieter wird zweimal
+ aufgerufen, mit den jeweils eigenen Zugangsdaten, und das Ergebnis enthaelt
+ die Kandidaten aus beiden Postfaechern.
+2. Von zwei Zeilen wirft die erste beim Abholen einen Fehler: das Ergebnis
+ enthaelt trotzdem die Datensaetze der zweiten, und eine Warnung wurde
+ protokolliert.
+3. Die Herkunftsmarkierung der erzeugten Datensaetze entspricht dem
+ Mandantenfeld der jeweiligen Zeile — bei zwei Zeilen unterschiedlicher
+ Mandanten also zwei unterschiedliche Werte.
+
+Wichtig: Die Erwartungswerte in diesen Tests von Hand hinschreiben. Sie duerfen
+**nicht** ueber dieselbe Hilfsfunktion erzeugt werden, die auch der Produktivcode
+benutzt — ein Test, der seine Erwartung mit dem Pruefling selbst baut, beweist
+nichts (dokumentierte Anti-Pattern im Projekt).
+
+**Tests `tender-email-config.service.spec.ts`.** Auf den neuen Schluessel
+umstellen: gelesen und geschrieben wird nach Besitzer. Zwei Faelle ergaenzen:
+Speichern legt beim Anlegen sowohl Besitzer als auch Mandant an; ein zweiter
+Nutzer im selben Mandanten erzeugt eine zweite Zeile statt die erste zu
+ueberschreiben. Der bestehende Fall, dass der verschluesselte Zugangsdatenblock
+nie in der Antwort auftaucht, bleibt erhalten und muss weiterhin durchlaufen.
+
+**Neuer Test `email-config-migration-sql.spec.ts`.** Nach dem Vorbild von
+`doe-url-migration-sql.spec.ts` die von Hand geschriebene Migrationsdatei ohne
+Datenbank pruefen, rein als Text: die Zuordnung zum Administrator kommt in der
+Datei vor dem Entfernen unbesetzter Zeilen; die Auswahl beschraenkt sich auf
+aktive Nutzer mit Administratorrolle desselben Mandanten und nimmt genau einen;
+die alte Eindeutigkeitsregel auf dem Mandantenfeld wird entfernt und eine neue
+auf dem Besitzerfeld angelegt; die Pflichtsetzung der neuen Spalte steht nach
+der Befuellung.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/adapters/email-alert.adapter.spec.ts src/tenders/tender-email-config.service.spec.ts src/tenders/email-config-migration-sql.spec.ts
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders
+
+
+Alle Tests im Bereich `src/tenders` laufen gruen. Der Fall "zwei Postfaecher
+desselben Mandanten werden beide abgeholt" existiert und besteht. Der Fall "ein
+kaputtes Postfach blockiert die anderen nicht" existiert und besteht. Die
+Migrationsdatei wird textuell geprueft.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser -> API (`/modules/tender-radar/email-config`) | Ab sofort erreicht jeder Nutzer mit Modulfreigabe diese Endpunkte, nicht mehr nur Administratoren. Besitzangaben aus der Anfrage sind grundsaetzlich nicht vertrauenswuerdig. |
+| API -> Datenbank (`TenderEmailConfig`) | Traegt verschluesselte Postfach-Zugangsdaten. |
+| Plattform-Zeitplan -> fremdes Postfach | Ausgehende Verbindung an eine vom Nutzer angegebene Adresse. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-17-01 | Elevation of Privilege | `GET/PUT /email-config` | high | mitigate | Besitzer und Mandant kommen ausschliesslich aus `extractTriageContext(req)`; die DTO traegt kein Besitzer- und kein Mandantenfeld. Test: ein zweiter Nutzer liest nie die Zeile des ersten. |
+| T-17-02 | Information Disclosure | Antwort von `GET /email-config` | high | mitigate | `EMAIL_CONFIG_SAFE_SELECT` schliesst den verschluesselten Zugangsdatenblock weiterhin aus; die Antwort traegt nur `hasPassword`. Bestehender Test bleibt aktiv. |
+| T-17-03 | Information Disclosure | Protokollausgabe im Abruf | medium | mitigate | Die neue Warnmeldung nennt nur Zeilen-`id` und Besitzer-Kennung, nie Benutzername, Passwort oder Postfachadresse. |
+| T-17-04 | Denial of Service | Abruf ueber mehrere Postfaecher | medium | mitigate | Fehlerbehandlung je Postfach bleibt erhalten — ein haengendes Postfach blockiert die uebrigen nicht. Test deckt den Fall ab. |
+| T-17-05 | Tampering | Hand geschriebene Migration | high | mitigate | Reihenfolge (befuellen vor loeschen, Pflicht nach Befuellung) wird textuell getestet; Anwendung zuerst nur lokal, Ausrollen auf den Testserver bleibt Nutzeraktion. |
+| T-17-06 | Elevation of Privilege | Wegfall der Rollenpruefung an den Postfach-Endpunkten | medium | accept | Beabsichtigt (D-01): das Postfach ist eine Nutzereinstellung. Der Zugang bleibt ueber die Modulfreigabe aus Phase 15 gedeckelt. |
+| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket — keine Lieferketten-Pruefung noetig. |
+
+
+
+1. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen.
+2. `pnpm --filter @tessera/api type-check` und `pnpm --filter @tessera/web type-check` — fehlerfrei.
+3. `npx prisma migrate status` im API-Verzeichnis — keine offene Migration.
+4. Indexliste von `TenderEmailConfig` enthaelt einen eindeutigen Index auf dem
+ Besitzerfeld und keinen mehr auf dem Mandantenfeld.
+5. Browser-Gegenprobe mit zwei Konten desselben Mandanten (siehe `human-check`
+ in Task 2).
+
+
+
+- Zwei Nutzer desselben Mandanten haben je eine eigene Postfach-Zeile.
+- Ein Nutzer sieht ueber die API ausschliesslich sein eigenes Postfach.
+- Der Abruf holt in einem Durchlauf alle aktiven Postfaecher; ein Ausfall
+ blockiert die anderen nicht.
+- Eine vorhandene Postfach-Zeile hat nach der Migration einen Besitzer.
+- `/modules/tender-radar/my-sources` ist fuer jeden Nutzer mit Modulfreigabe
+ erreichbar und zeigt dessen Postfach.
+- `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05).
+
+
+
diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md
new file mode 100644
index 0000000..aa7ddc5
--- /dev/null
+++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md
@@ -0,0 +1,417 @@
+---
+phase: 17-eigene-ausschreibungs-quellen-je-nutzer
+plan: 02
+type: execute
+wave: 2
+depends_on: ["17-01"]
+files_modified:
+ - apps/api/prisma/schema.prisma
+ - apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql
+ - apps/api/src/tenders/tender-rss-feed.service.ts
+ - apps/api/src/tenders/dto/tender-rss-feed.dto.ts
+ - apps/api/src/tenders/tenders.controller.ts
+ - apps/api/src/tenders/tenders.module.ts
+ - apps/api/src/tenders/adapters/rss.adapter.ts
+ - apps/api/src/tenders/tender-rss-feed.service.spec.ts
+ - apps/api/src/tenders/tenders.controller.spec.ts
+ - apps/api/src/tenders/adapters/rss.adapter.spec.ts
+ - apps/api/src/tenders/rss-feed-migration-sql.spec.ts
+autonomous: true
+requirements: [SRC-02, SRC-03]
+user_setup: []
+
+estimate:
+ tokens: 65000
+ raw_tokens: 65000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Ein Nutzer legt eigene RSS-Feeds an und sieht in seiner Liste nur die eigenen plus die plattformweiten (D-02)."
+ - "Der seit Phase 14 gesetzte service.bund.de-Feed bleibt fuer alle unveraendert aktiv (D-02, offener Punkt 2)."
+ - "Ein Nutzer kann keinen fremden Feed loeschen und keinen plattformweiten Feed anlegen — beides bleibt der Administration vorbehalten."
+ - "Die Sperrliste und der Schutz gegen interne Adressen greifen auch auf dem neuen, fuer jeden Nutzer offenen Weg."
+ - "Ausschreibungen aus einem persoenlichen Feed bleiben auf den Mandanten des Besitzers begrenzt; plattformweite Feeds bleiben fuer alle sichtbar."
+ artifacts:
+ - apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql
+ - apps/api/src/tenders/rss-feed-migration-sql.spec.ts
+ key_links:
+ - "TenderRssFeedSource.userId <-> extractTriageContext(req).userId — Besitz kommt aus dem angemeldeten Konto, nie aus dem Anfragerumpf."
+ - "TenderRssFeedSource.tenantId <-> RawTenderRecord.ownerTenantId — die vorhandene Herkunftsmarkierung aus Phase 14 traegt persoenliche Feeds, plattformweite Feeds bleiben ohne Markierung."
+ - "TendersModule.onModuleInit() <-> Startbestueckung des service.bund.de-Feeds — muss ohne die weggefallene Eindeutigkeitsregel auf der Adresse auskommen."
+---
+
+
+RSS-Feeds bekommen einen Besitzer. Heute gilt die Feed-Liste fuer alle, und wer
+einen Feed loescht, nimmt ihn allen weg. Nach diesem Plan gibt es zwei Sorten:
+plattformweite Feeds ohne Besitzer, von der Administration gepflegt und fuer
+alle aktiv (dazu gehoert der seit Phase 14 gesetzte service.bund.de-Feed), und
+persoenliche Feeds, die genau einem Nutzer gehoeren.
+
+Purpose: D-02 aus 17-CONTEXT.md umsetzen — "Wieso kann eine Person nicht mehrere
+Portale abfragen als eine andere?"
+Output: Erweiterte Tabelle samt Migration, Besitzerlogik im Dienst, Schutz gegen
+fremdes Loeschen, angepasste Startbestueckung, Herkunftsmarkierung im Abruf.
+
+**Gegenlesen von D-02, wie in 17-CONTEXT.md gewuenscht.** Der Zuschnitt
+"Besitzerfeld darf leer bleiben, leer heisst plattformweit" ist richtig: die
+Alternative haette den seit Phase 14 laufenden Standard-Feed fuer alle
+stillgelegt. Zwei technische Folgen muessen aber mitgeplant werden, sonst faellt
+die Entscheidung an anderer Stelle auf die Fuesse:
+
+1. In PostgreSQL gelten leere Werte in einer Eindeutigkeitsregel als jeweils
+ verschieden. `@@unique([userId, url])` verhindert deshalb **nicht**, dass
+ dieselbe Adresse zweimal plattformweit angelegt wird. Das ist hinnehmbar: die
+ Zusammenfuehrung gleicher Ausschreibungen laeuft ohnehin ueber den
+ Dedup-Schluessel, ein doppelter Feed kostet also nur einen zusaetzlichen
+ Abruf, verdoppelt aber keine Eintraege. Absichern liesse es sich nur mit
+ einem Teil-Index in Roh-SQL — den kennt Prisma nicht, er wuerde bei der
+ naechsten Schema-Abgleichung als Abweichung erscheinen und stillschweigend
+ wieder entfernt werden. Deshalb bewusst nicht.
+2. Aus demselben Grund laesst sich die Startbestueckung nicht mehr ueber die
+ Adresse als Schluessel schreiben. Sie wird auf "erst suchen, dann anlegen"
+ umgestellt — siehe Task 3.
+
+**Zusaetzliche Entscheidung (D-06, ueber 17-CONTEXT.md hinaus).** Persoenliche
+Feeds tragen zusaetzlich das Mandantenfeld ihres Besitzers, und die daraus
+eingelesenen Ausschreibungen bekommen dieselbe Herkunftsmarkierung, die
+Postfach-Ausschreibungen seit Phase 14 schon haben. Grund: ohne das wuerde ein
+persoenlicher Feed eines Nutzers Ausschreibungen in die Trefferliste **fremder
+Mandanten** spuelen — heute unmoeglich, weil die Feed-Liste ausschliesslich von
+der Administration gepflegt wird. Das waere keine Umsetzung von D-05, sondern
+eine Ausweitung ueber den heutigen Stand hinaus. Es wird kein neues
+Sichtbarkeitsmodell gebaut: benutzt wird ausschliesslich das bereits
+ausgelieferte Feld aus Phase 14, mit derselben Bedeutung. Innerhalb eines
+Mandanten bleibt weiterhin alles fuer alle sichtbar — genau wie mit dem Nutzer
+besprochen. Plattformweite Feeds bleiben unmarkiert und damit fuer alle
+sichtbar, exakt wie heute.
+
+**Harte Grenze (D-05) bleibt gewahrt:** `Tender` bekommt kein Mandantenfeld,
+keine RLS, keinen Mandanten-Wrapper. Die Trefferliste wird nicht nach Nutzern
+getrennt.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md
+
+@apps/api/prisma/schema.prisma
+@apps/api/src/tenders/tender-rss-feed.service.ts
+@apps/api/src/tenders/adapters/rss.adapter.ts
+@apps/api/src/tenders/dto/tender-rss-feed.dto.ts
+
+
+
+
+
+ Task 1: Ein persoenlicher Feed — durchgehend von der Datenbank bis zum Endpunkt
+ Die Migration aus Plan 17-01 ist lokal angewandt (`npx prisma migrate status` im API-Verzeichnis meldet keine offene Migration).
+ Die Migration ergaenzt nur Spalten ohne Pflichtwert und tauscht eine Eindeutigkeitsregel; kein Datenverlust, aber ein Rueckweg braucht eine zweite Migration. Die Freigabe hierfuer wurde bereits im Entscheidungspunkt in Plan 17-01 eingeholt, der beide Umbauten ausdruecklich benennt — deshalb hier kein zweiter Halt.
+
+ apps/api/prisma/schema.prisma,
+ apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql,
+ apps/api/src/tenders/tender-rss-feed.service.ts,
+ apps/api/src/tenders/dto/tender-rss-feed.dto.ts,
+ apps/api/src/tenders/tenders.controller.ts
+
+
+ apps/api/src/tenders/tender-rss-feed.service.ts,
+ apps/api/src/tenders/dto/tender-rss-feed.dto.ts,
+ apps/api/src/tenders/tenders.controller.ts (Zeilen 228-270),
+ apps/api/prisma/migrations/20260723111946_add_rss_feed_source_and_poll_granularity/migration.sql
+
+
+ - Nutzer A legt einen Feed an; die Liste von A enthaelt diesen Feed plus alle plattformweiten.
+ - Die Liste von Nutzer B enthaelt den Feed von A nicht, wohl aber dieselben plattformweiten.
+ - Zwei verschiedene Nutzer duerfen dieselbe Adresse verfolgen; das ist kein Konflikt mehr.
+ - Derselbe Nutzer kann dieselbe Adresse nicht zweimal anlegen.
+ - Jeder Eintrag der Liste sagt, ob er plattformweit ist oder dem Nutzer gehoert.
+
+
+**Schema (`schema.prisma`, Modell `TenderRssFeedSource`).** Zwei Felder ohne
+Pflichtwert ergaenzen: `userId String?` (leer = plattformweiter Feed) und
+`tenantId String?` (der Mandant des Besitzers, denormalisiert, leer bei
+plattformweiten Feeds — gleiche Rolle wie das gleichnamige Feld in
+`TenderEmailConfig` und `TenderMatch`). Die Eindeutigkeit auf `url` faellt weg
+und wird `@@unique([userId, url])`; `@@index([userId])` ergaenzen. Den
+Kopfkommentar des Modells umschreiben: die Liste ist ab Phase 17 zweigeteilt,
+und leere Werte in einer Eindeutigkeitsregel gelten in PostgreSQL als jeweils
+verschieden — die Regel deckt daher nur persoenliche Feeds ab (Begruendung siehe
+Objective).
+
+**Migration.** Verzeichnis
+`apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/` anlegen,
+`migration.sql` von Hand schreiben. Erwarteter Ablauf: beide Spalten ohne
+Pflichtwert ergaenzen, den alten eindeutigen Index auf der Adresse entfernen,
+neuen eindeutigen Index ueber Besitzer und Adresse anlegen, gewoehnlichen Index
+auf dem Besitzer anlegen. Bestandszeilen werden nicht angefasst — sie behalten
+einen leeren Besitzer und sind damit plattformweit, also genau der heutige
+Zustand (17-CONTEXT.md, offener Punkt 2). Deutscher Kommentarkopf wie bei den
+anderen handgeschriebenen Migrationen. Anwenden mit `prisma migrate deploy`
+gegen die lokale Datenbank, danach `prisma generate`.
+
+**Achtung beim erzeugten Prisma-Client:** Bei einer zusammengesetzten
+Eindeutigkeitsregel mit einem Feld ohne Pflichtwert erzeugt Prisma einen
+Suchtyp, in dem beide Bestandteile Pflicht sind — ein leerer Besitzer laesst
+sich darueber nicht ansprechen. Nach `prisma generate` den erzeugten Typ
+`TenderRssFeedSourceUserIdUrlCompoundUniqueInput` einmal aufschlagen und
+bestaetigen; alles, was auf diesem Weg einen plattformweiten Feed ansprechen
+will, muss ueber `findFirst`/`deleteMany` mit einer gewoehnlichen Bedingung
+gehen (betrifft die Startbestueckung, Task 3).
+
+**Datenvertrag (`dto/tender-rss-feed.dto.ts`).** Feld `scope` ergaenzen,
+optional, erlaubt nur die beiden Werte `personal` und `platform`, Vorgabe
+`personal`. Kein Besitzer- und kein Mandantenfeld in der DTO — beides kommt
+ausschliesslich aus dem angemeldeten Konto. Kommentar ergaenzen, dass `scope`
+nur ein Wunsch ist und die Berechtigung dafuer serverseitig geprueft wird.
+
+**Dienst (`tender-rss-feed.service.ts`).** Die vorhandene Adresspruefung
+(Schema, Sperrliste, private/lokale Adressen) bleibt **unveraendert** und
+laeuft weiterhin auf jedem Schreibweg — sie wird jetzt sogar wichtiger, weil
+nicht mehr nur Administratoren dorthin gelangen. Neu:
+- `listForUser(userId)`: liefert alle Zeilen mit leerem Besitzer plus die des
+ Nutzers, sortiert nach Anlagedatum aufsteigend.
+- `createForUser({ userId, tenantId }, dto)`: legt einen persoenlichen Feed an,
+ Besitzer und Mandant aus dem Aufruf.
+- `createPlatform(dto)`: legt einen Feed ohne Besitzer und ohne Mandant an.
+- Die alte `list()`/`create()`-Form entfaellt; `remove()` wird in Task 2
+ ersetzt.
+Klassenkommentar auf die neue Zweiteilung umschreiben.
+
+**Endpunkte (`tenders.controller.ts`).** Die drei RSS-Routen behalten **exakt
+ihre bisherige Position im Datei-Aufbau** — sie stehen vor der Parameter-Route
+`@Get(':id')`, sonst verdeckt diese sie und die Endpunkte antworten mit 404
+(dokumentierte Falle, Unit-Tests fangen sie nicht). Es werden bewusst **keine
+neuen Routen** angelegt, damit an dieser Reihenfolge nichts zu bewegen ist; die
+Unterscheidung laeuft ueber `scope` im Rumpf.
+- `GET /rss-feeds`: `@Roles(...)` entfaellt, `@UseModule('tender-radar')` tritt
+ an die Stelle. Ruft `listForUser(userId)` und ergaenzt je Eintrag ein
+ abgeleitetes Kennzeichen, ob der Eintrag plattformweit ist. Das Besitzerfeld
+ selbst braucht die Oberflaeche nicht — es aus der Antwort herausnehmen.
+- `POST /rss-feeds`: `@Roles(...)` entfaellt, `@UseModule('tender-radar')` tritt
+ an die Stelle. Bei `scope: 'platform'` zuerst pruefen, ob die Rolle des
+ angemeldeten Kontos ADMIN oder SUPER_ADMIN ist — sonst
+ `ForbiddenException`. Andernfalls persoenlich anlegen.
+Die Rolle aus demselben Ort lesen, aus dem sie auch die vorhandene
+Rollenpruefung liest (`roles.guard.ts` vorher aufschlagen und den Zugriffsweg
+uebernehmen). Dazu `extractTriageContext` um die Rolle erweitern, damit es
+genau eine Stelle gibt, an der Kontodaten aus der Anfrage gelesen werden.
+
+
+ cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check
+ cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL'
+SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'TenderRssFeedSource' ORDER BY indexname;
+SQL
+
+
+Die Indexliste zeigt einen eindeutigen Index ueber Besitzer und Adresse und
+keinen eindeutigen Index mehr allein auf der Adresse. Die Typpruefung laeuft
+fehlerfrei. Bestandszeilen haben einen leeren Besitzer.
+
+
+
+
+ Task 2: Niemand fasst fremde Feeds an — Loeschschutz und Mengenbegrenzung
+
+ apps/api/src/tenders/tender-rss-feed.service.ts,
+ apps/api/src/tenders/tenders.controller.ts,
+ apps/api/src/tenders/tender-rss-feed.service.spec.ts,
+ apps/api/src/tenders/tenders.controller.spec.ts
+
+
+ apps/api/src/tenders/tender-rss-feed.service.spec.ts,
+ apps/api/src/tenders/tenders.controller.spec.ts (Zeilen 1000-1050)
+
+
+ - Nutzer A loescht seinen eigenen Feed: erfolgreich.
+ - Nutzer A versucht den Feed von Nutzer B zu loeschen: der Feed bleibt bestehen, die Antwort ist "nicht gefunden" und verraet damit nicht, dass es ihn gibt.
+ - Nutzer A versucht einen plattformweiten Feed zu loeschen: bleibt bestehen, "nicht gefunden".
+ - Ein Administrator loescht einen plattformweiten Feed: erfolgreich.
+ - Ein Administrator loescht **nicht** ungefragt den persoenlichen Feed eines beliebigen Nutzers ueber diesen Weg.
+ - Ein Nutzer mit bereits 20 eigenen Feeds bekommt beim 21. eine verstaendliche Fehlermeldung statt eines Eintrags.
+ - Eine gesperrte oder interne Adresse wird auch auf dem persoenlichen Weg abgelehnt.
+
+
+**Dienst.** `remove(id, { userId, isAdmin })` ersetzt die bisherige
+`remove(id)`-Form. Geloescht wird in einer einzigen Anweisung ueber eine
+Mengenloeschung mit Bedingung: die Kennung stimmt **und** entweder gehoert der
+Eintrag dem Aufrufer, oder der Aufrufer ist Administrator und der Eintrag hat
+keinen Besitzer. Kommt dabei nichts zurueck, `NotFoundException` werfen — nicht
+`ForbiddenException`, damit die Antwort nicht verraet, dass ein fremder Eintrag
+mit dieser Kennung existiert. Bewusst als eine Anweisung statt "erst lesen, dann
+loeschen": kein Zeitfenster zwischen Pruefung und Ausfuehrung, und der
+Besitzvergleich liegt in der Datenbankbedingung, nicht im Anwendungscode.
+
+Ausserdem im Dienst eine Obergrenze fuer persoenliche Feeds einfuehren
+(Konstante, 20). `createForUser` zaehlt vorher die eigenen Eintraege des Nutzers
+und lehnt darueber hinaus mit einer verstaendlichen deutschen Meldung ab
+(`BadRequestException`). Grund: Der Anlegeweg steht ab dieser Phase jedem
+Modulnutzer offen und loest bei jedem Abruf eine ausgehende Verbindung je Feed
+aus — ohne Deckel waere das ein bequemer Hebel, den Plattform-Zeitplan
+zuzustellen. Plattformweite Feeds sind davon nicht betroffen.
+
+**Endpunkt.** `DELETE /rss-feeds/:feedId`: `@Roles(...)` entfaellt,
+`@UseModule('tender-radar')` tritt an die Stelle; Besitzer und Rolle kommen aus
+dem angemeldeten Konto und werden an den Dienst durchgereicht. Der
+Parametername `:feedId` bleibt wie er ist (er darf nie mit der
+Ausschreibungs-Kennung verwechselt werden), und die Position der Route im
+Datei-Aufbau bleibt unveraendert.
+
+**Tests.** In `tender-rss-feed.service.spec.ts` einen Prisma-Doppelgaenger
+benutzen, der die uebergebene Bedingung **tatsaechlich auswertet** (Vorbild:
+der auswertende Doppelgaenger in `tenders.controller.spec.ts` rund um Zeile
+1012) — ein Doppelgaenger, der die Bedingung ignoriert und einfach einen Erfolg
+zurueckmeldet, wuerde den Loeschschutz nur vortaeuschen. Alle sieben Faelle aus
+dem Abschnitt "behavior" abdecken, dazu die bestehenden Faelle zur
+Adresspruefung (Sperrliste, private Adressen, falsches Schema) unveraendert
+weiterlaufen lassen und einen davon zusaetzlich ueber den persoenlichen
+Anlegeweg fuehren.
+
+In `tenders.controller.spec.ts` zwei Faelle ergaenzen: ein Konto mit Rolle USER
+bekommt beim Anlegen mit `scope: 'platform'` eine Ablehnung; ein Konto mit
+Rolle ADMIN darf es. Erwartungswerte von Hand hinschreiben, nicht aus derselben
+Hilfsfunktion erzeugen, die auch der Produktivcode benutzt.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/tender-rss-feed.service.spec.ts src/tenders/tenders.controller.spec.ts
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check
+
+
+Alle sieben Verhaltensfaelle bestehen. Der Loeschversuch an einem fremden Feed
+laesst den Eintrag nachweislich stehen und antwortet mit "nicht gefunden". Der
+21. persoenliche Feed wird abgelehnt.
+
+
+
+
+ Task 3: Abruf und Startbestueckung ziehen nach
+
+ apps/api/src/tenders/adapters/rss.adapter.ts,
+ apps/api/src/tenders/tenders.module.ts,
+ apps/api/src/tenders/adapters/rss.adapter.spec.ts,
+ apps/api/src/tenders/rss-feed-migration-sql.spec.ts
+
+
+ apps/api/src/tenders/adapters/rss.adapter.ts,
+ apps/api/src/tenders/tenders.module.ts (Zeilen 265-305),
+ apps/api/src/tenders/adapters/rss.adapter.spec.ts,
+ apps/api/src/tenders/doe-url-migration-sql.spec.ts
+
+
+ - Der Abruf holt weiterhin alle aktiven Feeds in einem Durchlauf, plattformweite und persoenliche gemeinsam.
+ - Ein kaputter Feed wird uebersprungen und blockiert die uebrigen nicht.
+ - Datensaetze aus einem Feed mit Mandantenangabe tragen die Herkunftsmarkierung dieses Mandanten.
+ - Datensaetze aus einem plattformweiten Feed tragen keine Herkunftsmarkierung und bleiben damit fuer alle sichtbar — wie heute.
+ - Ein zweiter Start der Anwendung legt den service.bund.de-Feed nicht ein zweites Mal an.
+ - Der Feed aus der Startbestueckung hat keinen Besitzer.
+
+
+**Abruf (`rss.adapter.ts`).** Die Sammelabfrage ueber alle aktiven Feeds bleibt
+unveraendert — der Plattform-Zeitplan loest weiterhin alle Feeds in einem
+Durchlauf auf, und die Fehlerbehandlung je Feed bleibt bestehen. Neu ist
+ausschliesslich: hat die Feed-Zeile eine Mandantenangabe, bekommen die daraus
+erzeugten Datensaetze dieselbe Herkunftsmarkierung, die Postfach-Datensaetze
+seit Phase 14 tragen; hat sie keine, bleiben die Datensaetze unmarkiert. Die
+reine Umwandlungsfunktion `parseFeed` nicht anfassen — sie kennt keine
+Feed-Zeile, die Markierung gehoert in die Auffaecherung darueber.
+Klassenkommentar um zwei Saetze zu dieser Zweiteilung ergaenzen und darin auf
+D-06 aus 17-02-PLAN verweisen.
+
+**Startbestueckung (`tenders.module.ts`).** Das bisherige
+"anlegen-oder-aktualisieren ueber die Adresse" funktioniert nicht mehr, weil die
+Eindeutigkeit jetzt aus Besitzer und Adresse besteht und ein leerer Besitzer
+ueber diesen Weg nicht ansprechbar ist (siehe Hinweis in Task 1). Umstellen auf:
+erst suchen, ob bereits eine Zeile mit leerem Besitzer und dieser Adresse
+existiert; nur wenn nicht, anlegen — ohne Besitzer und ohne Mandant, sonst
+unveraendert (aktiv, Bezeichnung `service-bund`). Das bleibt bei wiederholten
+Starts folgenlos. Kommentar ergaenzen, warum der Weg gewechselt hat.
+
+**Tests `rss.adapter.spec.ts`.** Bestehende Faelle auf die neue Zeilenform
+nachziehen und drei ergaenzen: ein Feed mit Mandantenangabe erzeugt ausschliesslich
+markierte Datensaetze; ein Feed ohne Mandantenangabe erzeugt ausschliesslich
+unmarkierte; zwei Feeds unterschiedlicher Besitzer werden in einem Durchlauf
+beide abgeholt, und faellt der erste aus, kommen die Datensaetze des zweiten
+trotzdem an. Erwartungswerte von Hand hinschreiben.
+
+**Neuer Test `rss-feed-migration-sql.spec.ts`.** Nach dem Vorbild von
+`doe-url-migration-sql.spec.ts` die handgeschriebene Migrationsdatei rein
+textuell pruefen: beide neuen Spalten werden ohne Pflichtwert ergaenzt (sonst
+wuerde die Migration an Bestandszeilen scheitern); der alte eindeutige Index auf
+der Adresse wird entfernt; ein eindeutiger Index ueber Besitzer und Adresse
+entsteht; es gibt keine Anweisung, die Bestandszeilen aendert oder loescht.
+
+**Startbestueckung testen.** In der bestehenden Testdatei zur Bestueckung (oder,
+falls sie dafuer nicht passt, in `tender-rss-feed.service.spec.ts`) einen Fall
+ergaenzen, der die Bestueckung zweimal hintereinander ausfuehrt und zeigt, dass
+danach genau eine Zeile existiert und diese keinen Besitzer hat.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/adapters/rss.adapter.spec.ts src/tenders/rss-feed-migration-sql.spec.ts
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders
+
+
+Alle Tests im Bereich `src/tenders` laufen gruen. Der Fall "persoenlicher Feed
+erzeugt markierte Datensaetze" und der Fall "plattformweiter Feed erzeugt
+unmarkierte Datensaetze" bestehen beide. Die Bestueckung ist bei zweimaligem
+Lauf folgenlos.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser -> API (`/modules/tender-radar/rss-feeds`) | Der Anlegeweg fuer Feed-Adressen steht ab dieser Phase jedem Modulnutzer offen, nicht mehr nur Administratoren. |
+| API -> beliebige Internetadresse | Der Abruf holt Inhalte von Adressen, die ein Nutzer eingetragen hat. |
+| Nutzer A -> Datensatz von Nutzer B | Loeschen und Lesen ueber eine Kennung aus der Anfrage. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-17-07 | Elevation of Privilege | `DELETE /rss-feeds/:feedId` | high | mitigate | Mengenloeschung mit Besitzbedingung in einer einzigen Anweisung; nichts geloescht heisst "nicht gefunden". Test mit auswertendem Prisma-Doppelgaenger. |
+| T-17-08 | Elevation of Privilege | `POST /rss-feeds` mit `scope: 'platform'` | high | mitigate | Rollenpruefung im Handler gegen dieselbe Quelle, aus der auch die vorhandene Rollenpruefung liest; Test mit Rolle USER und Rolle ADMIN. |
+| T-17-09 | Tampering / SSRF | Adresspruefung im Dienst | high | mitigate | Die bestehende Pruefung (Schema, Sperrliste, private und lokale Adressen) bleibt unveraendert und laeuft auf beiden Anlegewegen; ein Test fuehrt einen Sperrfall ausdruecklich ueber den persoenlichen Weg. |
+| T-17-10 | Denial of Service | Anzahl persoenlicher Feeds | medium | mitigate | Obergrenze 20 je Nutzer im Dienst, mit verstaendlicher Ablehnung. Die vorhandene Groessen- und Zeitbegrenzung je Abruf bleibt bestehen. |
+| T-17-11 | Information Disclosure | Ausschreibungen aus persoenlichen Feeds | high | mitigate | Herkunftsmarkierung aus dem Mandanten des Besitzers (D-06) — verhindert, dass ein persoenlicher Feed in die Trefferliste fremder Mandanten spuelt. Zwei Tests. |
+| T-17-12 | Information Disclosure | Antwort von `GET /rss-feeds` | low | mitigate | Die Antwort enthaelt nur plattformweite Eintraege und die eigenen; das Besitzerfeld wird aus der Antwort herausgenommen. |
+| T-17-13 | Repudiation | Doppelter plattformweiter Feed | low | accept | Leere Werte gelten in einer Eindeutigkeitsregel als verschieden; ein doppelter Feed kostet nur einen zusaetzlichen Abruf, die Zusammenfuehrung gleicher Ausschreibungen faengt Doppel ab (Begruendung im Objective). |
+| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket. |
+
+
+
+1. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen.
+2. `pnpm --filter @tessera/api type-check` — fehlerfrei.
+3. `npx prisma migrate status` — keine offene Migration.
+4. Indexliste von `TenderRssFeedSource`: eindeutiger Index ueber Besitzer und
+ Adresse vorhanden, eindeutiger Index allein auf der Adresse verschwunden.
+5. Zaehlabfrage: alle Bestandszeilen haben einen leeren Besitzer, der
+ service.bund.de-Feed ist genau einmal vorhanden und aktiv.
+
+
+
+- Zwei Nutzer koennen dieselbe Feed-Adresse unabhaengig voneinander verfolgen.
+- Kein Nutzer kann einen fremden oder plattformweiten Feed loeschen.
+- Kein Nutzer ohne Administratorrolle kann einen plattformweiten Feed anlegen.
+- Der service.bund.de-Feed bleibt unveraendert fuer alle aktiv.
+- Ausschreibungen aus persoenlichen Feeds bleiben auf den Mandanten des
+ Besitzers begrenzt; aus plattformweiten Feeds bleiben sie fuer alle sichtbar.
+- `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05).
+
+
+
diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md
new file mode 100644
index 0000000..8b67c1e
--- /dev/null
+++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md
@@ -0,0 +1,363 @@
+---
+phase: 17-eigene-ausschreibungs-quellen-je-nutzer
+plan: 03
+type: execute
+wave: 3
+depends_on: ["17-01", "17-02"]
+files_modified:
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/page.tsx
+ - apps/web/src/lib/tender-radar-api.ts
+ - apps/web/src/messages/de.json
+ - apps/web/src/messages/en.json
+ - .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
+autonomous: true
+requirements: [SRC-04, SRC-05]
+user_setup: []
+
+estimate:
+ tokens: 55000
+ raw_tokens: 55000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Auf `/modules/tender-radar/my-sources` findet ein normaler Nutzer alles, was ihm gehoert: sein Postfach, seine Feeds, sein Benachrichtigungsintervall (D-01, D-02, D-04)."
+ - "Auf `/modules/tender-radar/settings` bleibt nur Administrationskram: Abrufintervall der oeffentlichen Quelle und die plattformweiten Feeds (D-03)."
+ - "Ein normaler Nutzer sieht auf der Administrationsseite keine Bedienelemente mehr, die beim Speichern in eine Fehlermeldung laufen (offener Punkt 5)."
+ - "Das Zahnrad auf der Modulseite fuehrt jeden Nutzer dorthin, wo er etwas einstellen darf."
+ - "Alle neuen Beschriftungen liegen in beiden Sprachdateien vor."
+ artifacts:
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx
+ - apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx
+ key_links:
+ - "RssFeedListForm(scope) <-> POST /rss-feeds mit `scope` — dieselbe Komponente bedient beide Seiten, die Berechtigung entscheidet der Server."
+ - "useAuthStore().user.role <-> Sichtbarkeit der Administrationsabschnitte — reine Anzeigefrage, die verbindliche Pruefung bleibt im Server."
+ - "Zahnrad in tender-radar/page.tsx <-> /modules/tender-radar/my-sources — der Weg, den jeder Nutzer gehen darf."
+---
+
+
+Die Oberflaeche wird nach Zustaendigkeit getrennt. Bisher stehen vier Abschnitte
+untereinander auf einer Seite, die drei verschiedenen Zustaendigkeiten gehoeren —
+wer nur seine Benachrichtigungen auf woechentlich stellen will, scrollt an
+Abrufintervallen, Feed-Adressen und Postfach-Zugangsdaten vorbei und sieht
+Bedienelemente, die er gar nicht bedienen darf.
+
+Nach diesem Plan gibt es zwei Seiten:
+
+- **Meine Quellen** (`/modules/tender-radar/my-sources`, fuer jeden mit
+ Modulzugang): mein Postfach, meine Feeds, mein Benachrichtigungsintervall.
+- **Einstellungen** (`/modules/tender-radar/settings`, nur Administration):
+ Abrufintervall der oeffentlichen Quelle und die plattformweiten Feeds.
+
+Purpose: D-03, D-04 und den urspruenglichen Anlass des Backlog-Punkts abschliessen.
+Output: Vollstaendige Nutzerseite, reduzierte und rollengepruefte
+Administrationsseite, angepasstes Zahnrad, Beschriftungen in beiden Sprachen.
+
+**Entscheidung zum offenen Punkt 5 (Rollenpruefung).** Die Administrationsseite
+prueft die Rolle des angemeldeten Kontos aus dem bereits vorhandenen
+Anmelde-Speicher im Browser und zeigt einem normalen Nutzer statt der
+Bedienelemente einen kurzen Satz mit einem Verweis auf "Meine Quellen".
+Begruendung: Die verbindliche Absicherung liegt und bleibt im Server — die
+Endpunkte fuer das Abrufintervall und fuer plattformweite Feeds sind dort
+rollengeprueft. Die Pruefung im Browser ist ausschliesslich eine Anzeigefrage:
+sie verhindert, dass jemand Knoepfe sieht, die beim Speichern in eine
+Fehlermeldung laufen. Genau das war der Anlass des Backlog-Punkts.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md
+@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-SUMMARY.md
+@.planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
+
+@apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
+@apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx
+@apps/web/src/lib/tender-radar-api.ts
+@apps/web/src/lib/stores/auth-store.ts
+
+
+
+
+
+ Task 1: "Meine Quellen" wird vollstaendig — Postfach, eigene Feeds, Benachrichtigung
+
+ apps/web/src/lib/tender-radar-api.ts,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx,
+ apps/web/src/messages/de.json,
+ apps/web/src/messages/en.json
+
+
+ apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx,
+ apps/web/src/lib/tender-radar-api.ts (Zeilen 370-560)
+
+
+ - Auf "Meine Quellen" stehen drei Abschnitte untereinander: Mein Postfach, Meine Feeds, Benachrichtigung.
+ - Die Feed-Liste dort zeigt ausschliesslich die eigenen Feeds; plattformweite Feeds erscheinen darunter als kurze, nicht bedienbare Aufzaehlung mit dem Hinweis, dass die Administration sie pflegt.
+ - Ein neu angelegter Feed erscheint sofort in der eigenen Liste.
+ - Zu einem plattformweiten Feed gibt es auf dieser Seite keinen Entfernen-Knopf.
+ - Die Auswahl des Benachrichtigungsintervalls speichert und quittiert wie bisher.
+
+
+**Zugriffsschicht (`lib/tender-radar-api.ts`).** Der Typ `RssFeedSource` bekommt
+das vom Server gelieferte Kennzeichen, ob der Eintrag plattformweit ist.
+`createRssFeed` bekommt einen zweiten Parameter fuer den Wirkungsbereich
+(`personal` oder `platform`, Vorgabe `personal`), der in den Rumpf der Anfrage
+wandert. `listRssFeeds` und `deleteRssFeed` bleiben in ihrer Signatur, ihr
+Verhalten aendert sich serverseitig.
+
+**Feed-Liste (`RssFeedListForm.tsx`).** Die Komponente bekommt eine
+Eigenschaft `scope` mit den Werten `personal` und `platform`. Sie laedt
+weiterhin die eine Liste vom Server und teilt sie selbst auf:
+- `scope="personal"`: bearbeitbar sind die eigenen Eintraege (anlegen,
+ entfernen). Die plattformweiten Eintraege erscheinen darunter als schlichte
+ Aufzaehlung ohne Knoepfe, mit einem Satz, dass die Administration sie pflegt —
+ so ist auf einen Blick klar, dass niemand bei null anfaengt.
+- `scope="platform"`: bearbeitbar sind die plattformweiten Eintraege; angelegt
+ wird mit dem entsprechenden Wirkungsbereich. Eigene Eintraege anderer Nutzer
+ tauchen hier gar nicht auf, weil der Server sie nicht liefert.
+Die Fehleranzeige bleibt wie sie ist: eine abgelehnte Adresse zeigt weiterhin
+die ausfuehrliche Meldung des Servers, nicht einen selbstgebauten Ersatztext.
+Die Ablehnung wegen erreichter Obergrenze (aus Plan 17-02) laeuft ueber denselben
+Weg und braucht keine Sonderbehandlung.
+
+**Benachrichtigungsintervall (`DigestIntervalForm.tsx`).** Den heute in der
+Einstellungsseite eingebetteten Block (Laden beim Aufbau, Auswahlfeld
+Taeglich/Woechentlich/Aus, Speichern bei Aenderung, Erfolgs- und Fehlermeldung)
+unveraendert in eine eigene Komponente herausloesen. Kein Verhalten aendern,
+keine neuen Beschriftungsschluessel — nur verschieben, damit beide Seiten
+dieselbe Umsetzung benutzen koennen und die Einstellungsseite in Task 2 sauber
+kleiner wird.
+
+**Seite (`my-sources/page.tsx`).** Die in Plan 17-01 angelegte Seite um zwei
+Abschnitte erweitern: unter "Mein Postfach" folgt "Meine Feeds"
+(`RssFeedListForm` mit `scope="personal"`), darunter "Benachrichtigung"
+(`DigestIntervalForm`). Aufbau, Abstaende und Trennlinien wie auf der heutigen
+Einstellungsseite uebernehmen, damit nichts fremd wirkt. Fuer Konten mit
+Administratorrolle unten ein Verweis auf die Administrationsseite; fuer alle
+anderen erscheint dieser Verweis nicht. Die Rolle kommt aus dem vorhandenen
+Anmelde-Speicher; solange dieser noch nicht befuellt ist, den Verweis
+weglassen statt kurz aufblitzen zu lassen.
+
+**Beschriftungen (`messages/de.json`, `messages/en.json`).** Alle neuen
+Schluessel im Namensraum `tenderRadar` unter `mySources` anlegen und in beiden
+Dateien pflegen: Seitentitel, die drei Abschnittsueberschriften, der ehrliche
+Hinweis zu D-05 (Ausschreibungen aus den eigenen Quellen erscheinen auch bei
+Kollegen desselben Mandanten), der Satz zu den von der Administration
+gepflegten Feeds, der Verweis auf die Administrationsseite. Keine Beschriftung
+nur auf Deutsch anlegen.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check
+ cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')"
+ Als normaler Nutzer `/modules/tender-radar/my-sources` oeffnen: drei Abschnitte sind sichtbar. Einen eigenen RSS-Feed anlegen — er erscheint in der eigenen Liste, der plattformweite service.bund.de-Feed steht darunter ohne Entfernen-Knopf. Das Benachrichtigungsintervall auf "Woechentlich" stellen, Seite neu laden, Wert steht noch. Kein Verweis auf die Administrationsseite sichtbar.
+
+
+Die Typpruefung laeuft fehlerfrei, beide Sprachdateien decken denselben
+Schluesselsatz ab. Ein normaler Nutzer kann auf einer Seite Postfach, eigene
+Feeds und Benachrichtigungsintervall pflegen und sieht keinen Knopf, der ihm
+verwehrt ist.
+
+
+
+
+ Task 2: Die Administrationsseite schrumpft auf das, was Administration ist
+
+ apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/page.tsx,
+ apps/web/src/messages/de.json,
+ apps/web/src/messages/en.json
+
+
+ apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/page.tsx (Zeilen 60-95),
+ apps/web/src/lib/stores/auth-store.ts
+
+
+ - Ein Konto mit Rolle ADMIN oder SUPER_ADMIN sieht auf der Einstellungsseite genau zwei Abschnitte: Abrufintervall und plattformweite Feeds.
+ - Ein Konto mit Rolle USER sieht dort keinen dieser Abschnitte, sondern einen kurzen Satz und einen Verweis auf "Meine Quellen".
+ - Solange die Kontodaten im Browser noch nicht geladen sind, erscheint weder das eine noch das andere, sondern ein Platzhalter — kein kurzes Aufblitzen der Administrationsabschnitte.
+ - Postfach und Benachrichtigungsintervall kommen auf dieser Seite nicht mehr vor.
+ - Das Zahnrad auf der Modulseite fuehrt fuer jeden Nutzer nach "Meine Quellen".
+
+
+**Einstellungsseite (`settings/page.tsx`).** Die Abschnitte "E-Mail-Alerts" und
+"Benachrichtigungen" ersatzlos herausnehmen — sie leben ab jetzt auf "Meine
+Quellen" (D-01, D-04). Uebrig bleiben das Abrufintervall (`SourceConfigForm`,
+D-03) und die plattformweiten Feeds (`RssFeedListForm` mit `scope="platform"`).
+Der ganze Inhalt wird von einer Rollenpruefung umschlossen: Rolle aus dem
+vorhandenen Anmelde-Speicher lesen; ist sie noch nicht bekannt, einen
+Ladeplatzhalter zeigen; ist sie ADMIN oder SUPER_ADMIN, den Inhalt zeigen; sonst
+einen kurzen Satz ("Diese Seite verwaltet plattformweite Einstellungen und ist
+Administratoren vorbehalten.") mit einem Verweis auf `/modules/tender-radar/my-sources`.
+Der Kopfkommentar der Datei wird auf den neuen Zuschnitt umgeschrieben; die
+Absaetze zu den entfernten Abschnitten entfallen, ein Absatz zu Phase 17 kommt
+hinzu. Beim Abschnitt der plattformweiten Feeds einen Satz ergaenzen, dass diese
+fuer alle Nutzer gelten und persoenliche Feeds woanders liegen.
+
+**Modulseite (`page.tsx`).** Das Zahnrad zeigt heute auf die
+Administrationsseite. Es zeigt kuenftig auf `/modules/tender-radar/my-sources` —
+das ist die Seite, die jeder Nutzer aufrufen darf, und Administratoren gehen von
+dort mit einem Klick weiter. Beschriftung und Vorlesetext des Knopfes
+entsprechend anpassen (neuer Schluessel, alter Schluessel bleibt fuer den Verweis
+auf der Nutzerseite in Gebrauch, falls passend — sonst entfernen). Den
+vorhandenen Kommentar zum absoluten Verweis (die dynamische Einstellungsroute
+greift fuer dieses Modul nicht) beibehalten und auf den neuen Zielpfad
+anpassen.
+
+**Beschriftungen.** Neue Schluessel in beiden Sprachdateien anlegen: der
+Hinweistext fuer Nutzer ohne Administratorrolle, der Verweistext auf "Meine
+Quellen", der Zusatzsatz beim Abschnitt der plattformweiten Feeds, die neue
+Zahnrad-Beschriftung. Nicht mehr benutzte Schluessel (`settings.emailSectionTitle`,
+`settings.emailSectionBody`, `settings.notificationsSectionTitle` und die
+Digest-Schluessel, sofern sie auf die Nutzerseite umziehen) erst entfernen,
+nachdem im gesamten `apps/web/src` nachgesehen wurde, dass sie nirgends mehr
+verwendet werden.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check
+ cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')"
+ Als Konto mit Rolle USER `/modules/tender-radar/settings` direkt in der Adresszeile aufrufen: es erscheinen keine Bedienelemente, sondern der Hinweis samt Verweis. Danach als Administrator dieselbe Adresse: Abrufintervall und plattformweite Feeds sind da, Postfach und Benachrichtigungsintervall nicht mehr. Auf der Modulseite fuehrt das Zahnrad in beiden Faellen nach "Meine Quellen".
+
+
+Die Einstellungsseite traegt nur noch Abrufintervall und plattformweite Feeds
+und ist fuer Konten ohne Administratorrolle leer bis auf Hinweis und Verweis.
+Das Zahnrad fuehrt jeden Nutzer auf eine Seite, auf der er etwas einstellen darf.
+Beide Sprachdateien decken denselben Schluesselsatz ab.
+
+
+
+
+ Task 3: Tests fuer beide Seiten und der Backlog-Punkt wird zugemacht
+
+ apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx,
+ .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
+
+
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx,
+ apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx
+
+
+ - `RssFeedListForm` mit `scope="personal"`: die gelieferte Liste enthaelt einen plattformweiten und einen eigenen Eintrag; nur der eigene traegt einen Entfernen-Knopf, der plattformweite erscheint ohne.
+ - `RssFeedListForm` mit `scope="platform"`: umgekehrt.
+ - `RssFeedListForm` mit `scope="personal"` schickt beim Anlegen den Wirkungsbereich `personal` mit, mit `scope="platform"` entsprechend `platform`.
+ - Einstellungsseite mit Rolle USER: die Ueberschriften der Administrationsabschnitte kommen nicht vor, der Verweis auf "Meine Quellen" schon.
+ - Einstellungsseite mit Rolle ADMIN: die Administrationsabschnitte kommen vor.
+ - Einstellungsseite ohne geladene Kontodaten: weder das eine noch das andere.
+ - "Meine Quellen": alle drei Abschnittsueberschriften kommen vor; der Verweis auf die Administrationsseite nur bei Rolle ADMIN.
+
+
+**Testaufbau.** Vorbild sind die vorhandenen Tests im selben Verzeichnis: die
+Zugriffsschicht `@/lib/tender-radar-api` wird ersetzt, `next-intl` wird durch
+eine Zuordnung von Schluessel auf Text ersetzt. Neu hinzu kommt das Ersetzen des
+Anmelde-Speichers, damit die Rolle im Test gesetzt werden kann — dieselbe
+Ersetzungsform wie bei den uebrigen Ersetzungen, ueber den Modulpfad des
+Speichers.
+
+**Wichtig zur Aussagekraft.** Die Pruefungen muessen an dem haengen, was der
+Nutzer sieht: vorhandene beziehungsweise fehlende Beschriftungen und Knoepfe im
+gerenderten Ergebnis. Nicht pruefen, ob eine Komponente "aufgerufen wurde" —
+und die erwarteten Texte im Test woertlich hinschreiben, nicht aus derselben
+Zuordnung ableiten, die auch die Komponente benutzt. Ein Test, der seine
+Erwartung mit dem Pruefling selbst baut, beweist nichts.
+
+**`RssFeedListForm.test.tsx`.** Bestehende Faelle auf die neue Eigenschaft
+`scope` nachziehen (Vorgabe im Test ausdruecklich setzen, nicht raten) und die
+drei Faelle aus dem Abschnitt "behavior" ergaenzen. Der bestehende Fall, dass die
+ausfuehrliche Servermeldung bei einer gesperrten Adresse angezeigt wird, muss
+unveraendert weiterlaufen.
+
+**`settings-roles.test.tsx` (neu).** Die drei Rollenfaelle der
+Einstellungsseite. Zusaetzlich ein Fall, der belegt, dass die Ueberschriften der
+entfernten Abschnitte (Postfach, Benachrichtigung) auf dieser Seite nicht mehr
+vorkommen.
+
+**`my-sources.test.tsx` (neu).** Die drei Abschnitte sind da; der Verweis auf
+die Administrationsseite erscheint nur bei Administratorrolle.
+
+**Backlog-Punkt.** `2026-08-11-tender-radar-einstellungen-mischen-rollen.md`
+nach `.planning/todos/completed/` verschieben und im Kopf einen kurzen Abschnitt
+ergaenzen, wie er geloest wurde: nicht nur die Rollenpruefung aus der
+urspruenglichen Skizze, sondern der Umbau auf nutzereigene Quellen, und die
+Grundsatzfrage aus Punkt 2 ist mit "eigene nutzerseitige Modulseite" beantwortet
+— das ist ab jetzt die Bauform, an der sich DKV-Fleet und kuenftige Module
+orientieren.
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run
+ cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check
+ test ! -f .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md && test -f .planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md
+
+
+Alle Tests in Web und API laufen gruen, beide Typpruefungen sind sauber, der
+Backlog-Punkt liegt im Ordner der erledigten Punkte.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser-Anzeige -> Serverpruefung | Die Rollenpruefung im Browser ist eine Anzeigefrage; die verbindliche Pruefung liegt im Server. |
+| Nutzerseite -> Endpunkte fuer plattformweite Einstellungen | Die Nutzerseite darf keinen Weg zu plattformweiten Schreibvorgaengen oeffnen. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-17-14 | Elevation of Privilege | Rollenpruefung im Browser | medium | mitigate | Die Pruefung blendet nur Bedienelemente aus. Die Endpunkte fuer Abrufintervall und plattformweite Feeds bleiben serverseitig rollengeprueft (Plan 17-02, T-17-08). Ein Test belegt, dass die Rolle USER die Abschnitte nicht zu sehen bekommt. |
+| T-17-15 | Elevation of Privilege | `RssFeedListForm` mit `scope="personal"` | high | mitigate | Der Wirkungsbereich wandert als Wunsch in die Anfrage; ob er gewaehrt wird, entscheidet ausschliesslich der Server. Ein manipuliertes Frontend erreicht nichts. |
+| T-17-16 | Information Disclosure | Kurzes Aufblitzen der Administrationsabschnitte vor dem Laden der Kontodaten | low | mitigate | Solange die Rolle unbekannt ist, wird ein Platzhalter gezeigt; ein Test deckt genau diesen Zustand ab. |
+| T-17-17 | Repudiation | Unvollstaendige Uebersetzung | low | mitigate | Ein Pruefschritt vergleicht die Schluesselsaetze beider Sprachdateien und bricht bei Abweichung ab. |
+| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket. |
+
+
+
+1. `pnpm --filter @tessera/web exec vitest run` — vollstaendig gruen.
+2. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen.
+3. Beide Typpruefungen fehlerfrei.
+4. Schluesselvergleich der beiden Sprachdateien ohne Abweichung.
+5. Browser-Gegenprobe mit zwei Konten (Rolle USER und Rolle ADMIN) wie in den
+ `human-check`-Schritten der Tasks 1 und 2. Eine erfolgreiche Antwort des
+ Servers allein reicht als Nachweis nicht — es zaehlt, was auf der Seite steht.
+
+
+
+- "Meine Quellen" traegt Postfach, eigene Feeds und Benachrichtigungsintervall.
+- Die Einstellungsseite traegt nur Abrufintervall und plattformweite Feeds.
+- Ein normaler Nutzer sieht auf der Einstellungsseite keine Bedienelemente mehr,
+ die beim Speichern in eine Fehlermeldung laufen.
+- Das Zahnrad fuehrt jeden Nutzer auf eine fuer ihn bedienbare Seite.
+- Alle Beschriftungen liegen in Deutsch und Englisch vor.
+- Der Backlog-Punkt ist abgeschlossen und die Grundsatzfrage darin beantwortet.
+
+
+