docs: CalDAV two-way sync (ADR-0017 addendum, roadmap, architecture, README, deploy)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 22:20:03 +02:00
1 parent 49a0fb8ec5
commit 91b42c8907
6 files changed
+109 -7

No files matched your search

+18 -1
View File
@@ -124,8 +124,25 @@ unter **Verwaltung › Kalender**.
* **Google Kalender:** *Weitere Kalender „+“ › Per URL*.
Voraussetzungen: Die App muss für die Geräte per **HTTPS** mit gültigem Zertifikat erreichbar sein (z. B. Caddy-Profil `proxy`).
Abos sind nur lesend; Änderungen erfolgen in der App (bidirektionale Synchronisation: [ADR-0017](docs/adr/0017-caldav-und-graph-spaeter.md)).
Abos sind nur lesend; für Änderungen vom Handy aus gibt es die Zwei-Wege-Synchronisation (siehe nächster Abschnitt).
Der Feed-Endpunkt ist auf 60 Abrufe pro Minute und IP begrenzt (`RateLimiting__FeedPermitLimit`).
## Kalender mit dem Handy verbinden (CalDAV, Zwei-Wege)
Unter **Einstellungen › Kalender verbinden** (auch im Kalender-Menü: *Mit Handy verbinden*) erscheinen Server-Adresse
(`https://<Domain>/caldav/`) und Benutzername. Dort ein **App-Passwort** pro Gerät erstellen (wird nur einmal angezeigt, jederzeit
widerrufbar) und in der Kalender-App eintragen – **nie das normale Passwort**. Die Familienkalender erscheinen dann in
**iPhone/iPad/Mac** (*Account hinzufügen › Andere › CalDAV-Account*), **Thunderbird** (*Neuer Kalender › Im Netzwerk › CalDAV*) und
**Android** (App *DAVx5*); Termine, die dort angelegt oder geändert werden, landen im Familienplaner und umgekehrt. Es gelten dieselben
Rechte wie in der App (Kinder ändern nur eigene Termine, Gäste lesen nur).
* **HTTPS ist Pflicht** (`CALDAV_REQUIRE_HTTPS=true`, Standard): Kalender-Apps senden das App-Passwort bei jeder Abfrage per HTTP Basic.
Nur in einem reinen Heimnetz ohne Zertifikat kann es auf `false` gestellt werden (Klartext im LAN).
* Bei falschen Zugangsdaten sperrt der Server weitere Versuche kurz (429); iOS stellt beim ersten Abgleich viele Anfragen
(`RateLimiting__CalDavPermitLimit`, Standard 600 pro Minute und IP).
* **Outlook** kann kein CalDAV: dort bleibt nur das lesende ICS-Abo (Zwei-Wege-Abgleich mit Outlook wäre nur über Microsoft Graph möglich,
siehe Backlog). Nicht gespeichert werden Erinnerungen, Teilnehmer und Anhänge; Wiederholungen außerhalb der unterstützten Regeln
(z. B. stündlich) und Aufgaben werden vom Server abgelehnt. Details und Grenzen: [ADR-0017](docs/adr/0017-caldav-und-graph-spaeter.md).
**ICS-Import** (Schulferien, Müllabfuhr …): *Kalender › Filter-Menü › .ics importieren* bzw. *Verwaltung › Kalender*. Bereits importierte
Termine (gleiche UID) werden übersprungen; nicht unterstützte Wiederholungsregeln werden im Ergebnis genannt.
+4
View File
@@ -24,6 +24,10 @@ VAPID_SUBJECT=mailto:admin@example.org
# API-Schlüssel von developers.giphy.com. Es geht nur der Suchbegriff über den Server an Giphy (Altersstufe „g“). Leer = kein GIF-Button.
GIPHY_API_KEY=
# --- Kalender-Synchronisation (CalDAV) ------------------------------------------
# true (Standard): Kalender-Apps dürfen sich nur über HTTPS anmelden (App-Passwort wird bei jeder Abfrage gesendet). false nur im Heimnetz ohne Zertifikat.
CALDAV_REQUIRE_HTTPS=true
# --- Netzwerk -------------------------------------------------------------------
# Ohne Caddy: Port des Web-Containers auf dem Host (127.0.0.1 = nur lokal bzw. für eigenen Reverse Proxy).
WEB_BIND=127.0.0.1
+2
View File
@@ -70,6 +70,8 @@ services:
WebPush__Subject: ${VAPID_SUBJECT:-mailto:admin@localhost}
# GIF-Suche im Chat (optional, ADR-0022) – leer = GIF-Button ausgeblendet
Giphy__ApiKey: ${GIPHY_API_KEY:-}
# Kalender-Synchronisation (CalDAV, ADR-0017): Basic-Auth mit App-Passwort nur über HTTPS (true). false nur im reinen Heimnetz ohne Zertifikat.
CalDav__RequireHttps: ${CALDAV_REQUIRE_HTTPS:-true}
depends_on:
migrations:
condition: service_completed_successfully
+6 -3
View File
@@ -13,7 +13,7 @@ Kalender mit ICS-Abo, Chat). Lokal wird alles über **.NET Aspire** orchestriert
┌───────────────────────────────────────────────────────────────┐
│ FamilyPlanner.Web (BFF) │
│ • liefert WASM-App, Manifest, Service Worker (statisch) │
│ • YARP: /api/**, /hubs/**, /ics/** ──────────────┐ │
│ • YARP: /api/**, /hubs/**, /ics/**, /caldav/** ──┐ │
│ • Security-Header (CSP, HSTS …) │ │
└───────────────────────────────────────────────────┼───────────┘
▼
@@ -22,7 +22,7 @@ Kalender mit ICS-Abo, Chat). Lokal wird alles über **.NET Aspire** orchestriert
│ • Minimal APIs (Vertical Slices pro Feature) │
│ • ASP.NET Core Identity + Cookie-Auth, Policies │
│ • SignalR Hubs (Chat, Live-Updates) │
│ • ICS-Feeds (Token-gesichert), ICS-Import │
│ • ICS-Feeds (Token-gesichert), ICS-Import, CalDAV (/caldav) │
└───────────────────────────┬───────────────────────────────────┘
│ EF Core 10
▼
@@ -64,6 +64,7 @@ FamilyPlanner.Api/
Users/… # /api/admin/users/* (Phase 2)
Shopping/… # /api/shopping/* (Phase 3)
Meals/… Tasks/… Calendar/… Chat/… Dashboard/…
CalDav/… # /caldav/* (CalDAV-Server) + /api/caldav/* (App-Passwörter), Phase 12c
Hubs/ # SignalR
Infrastructure/ # Auth-Setup, Security, Rate Limiting
```
@@ -84,7 +85,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt). Die Items-DTOs liefern `CreatedByName` („hinzugefügt von …“); für Kinder prüft `ShoppingPlausibility` (Sonderzeichen/Ziffern, Zeichenwiederholung, Tastaturmuster, kleine Quatschliste) Neu-Einträge: verdächtige liefern 409 mit `needsConfirmation`, der Client fragt nach und sendet `Confirm = true` |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
@@ -139,6 +140,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
* Login per Benutzername **oder** E-Mail, „Angemeldet bleiben“ (persistentes Cookie, 30 Tage gleitend), Lockout (5 Fehlversuche → 15 min), Rate Limiting (Fixed Window pro IP auf `/api/auth/login`).
* Seed-Admin aus Konfiguration (`Seed:AdminUserName`, `Seed:AdminPassword`, `Seed:AdminEmail`), `MustChangePassword = true`.
* Kalender-Abos (ICS) verwenden geheime Tokens in der URL (nur Hash in DB), da Kalender-Clients keine Cookies können (ADR-0005).
* CalDAV (`/caldav`, ADR-0017) verwendet **HTTP Basic mit App-Passwörtern** (`CalDavCredential`: je Benutzer/Gerät, einmalig angezeigt, nur SHA-256 in der DB, widerrufbar) über ein eigenes, nicht standardmäßiges Auth-Schema `CalDavBasic`: das Konto-Passwort wird nie akzeptiert, App-Passwörter öffnen kein `/api`, Cookies kein `/caldav`. Basic nur über HTTPS (`CalDav:RequireHttps`), Fehlversuche werden gedrosselt (429); Kalenderrechte werden pro Anfrage serverseitig geprüft.
## 6. API-Übersicht (Ziel, ✅ = umgesetzt)
@@ -156,6 +158,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| Kalender-Abos ✅ | `GET/POST /api/calendar/feeds`, `POST /api/calendar/feeds/{id}/regenerate`, `DELETE /api/calendar/feeds/{id}`; Admin: `GET/DELETE /api/admin/calendar-feeds` |
| Admin – Kalender ✅ | `GET/POST /api/admin/calendars`, `PUT/DELETE /api/admin/calendars/{id}` |
| ICS-Feed | `GET /ics/{token}.ics` (öffentlich, Token-gesichert, ETag/Last-Modified) |
| CalDAV ✅ | `/.well-known/caldav` → 301, `/caldav/**` (`OPTIONS`, `PROPFIND`, `PROPPATCH`, `REPORT` calendar-query/calendar-multiget/sync-collection, `GET`, `PUT`, `DELETE`; Basic mit App-Passwort), `GET /api/caldav/info`, `GET/POST /api/caldav/credentials`, `DELETE /api/caldav/credentials/{id}` (ADR-0017) |
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019); Erweiterungen (ADR-0022): `GET /api/chat/features`, `POST /api/chat/ai` (eigener KI-Kanal, get-or-create), `GET /api/chat/gifs?q=`, `GET /api/chat/gifs/{id}/preview`, `POST …/channels/{id}/gifs`; Konfiguration `Giphy__ApiKey` (leer = keine GIF-Suche) |
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`; Eltern: `GET /api/ai/status` (ADR-0020) |
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, KI-Vorschlag für Fotos und unklare Zahlenantworten); Automatik (11c, alle Routen `LearningManage` + `AiUse`): `GET/POST /api/learning/schedules`, `PUT/DELETE …/schedules/{id}`, `POST …/schedules/{id}/run` (ADR-0021) |
+12 -2
View File
@@ -167,6 +167,16 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [ ] Backup-/Restore-Skripte
- [ ] README: Voraussetzungen, Entwicklung, Konfiguration, Deployment, Update, Backup/Restore, Kalender-Abos
## Phase 12c – CalDAV-Zwei-Wege-Sync
- [x] Kalender freigeben und Zwei-Wege-Synchronisation mit iOS/macOS, Thunderbird und DAVx5 (CalDAV-Server in der Api unter `/caldav`, `/.well-known/caldav` → 301; BFF leitet beides weiter; ADR-0017 Nachtrag)
- [x] Protokoll: Entdeckung (Principal, calendar-home-set), `OPTIONS`/`PROPFIND` (Depth 0/1)/`REPORT` (calendar-query inkl. time-range, calendar-multiget, sync-collection)/`GET`/`PUT`/`DELETE`, ETag je Termin (Serie + Ausnahmen), CTag/Sync-Token je Kalender, If-Match/If-None-Match mit 412
- [x] Abbildung auf die vorhandenen Termine: ein `.ics` je UID (Serie + RECURRENCE-ID), Import-/Export-Code aus ADR-0016 wiederverwendet; nicht unterstützte Wiederholungen und VTODO/VJOURNAL werden mit 403 und Vorbedingung abgelehnt; Erhalt von Ersteller/Personen-Tags
- [x] Anmeldung nur per App-Passwort (`CalDavCredential`: gehasht, einmalig angezeigt, widerrufbar, je Benutzer/Gerät, zuletzt benutzt), HTTP Basic nur über HTTPS (`CalDav:RequireHttps`), eigenes Auth-Schema ohne Zugriff auf `/api`, Drosselung von Fehlversuchen, Audit-Log; Migration `Phase12cCalDav`
- [x] Rechte je Kalender und Benutzer bei jeder Anfrage (Kind nur eigene Termine, Gast nur lesen, unsichtbare Kalender), `current-user-privilege-set` je Kalender und Termin
- [x] Oberfläche „Kalender freigeben / Mit Handy verbinden“ (Einstellungen › Kalender verbinden, Link im Kalender-Menü): Server-Adresse, Benutzername, App-Passwort erstellen/widerrufen, Anleitungen iPhone/Mac/Android (DAVx5)/Thunderbird, ehrlicher Outlook-Hinweis (nur ICS-Abo lesend)
- [x] Tests: Unit (Pfade/Hrefs, ETag, Gruppierung, Sync-Token, XML, Filter, PUT-Prüfung, Export-Roundtrip, Drosselung), bUnit (Verbindungsseite), Integration über den BFF (Entdeckung, Round-Trip, 412, Serien, Sync, Rechte, Zugangsdaten)
- [ ] Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5) – noch nicht erfolgt
## Backlog
- PIN-Login-Oberfläche für ein Familien-Tablet (Profilauswahl + Ziffernblock) – Api ist vorbereitet
- Selbst-Upload des eigenen Profilbilds, Profilseite für Anzeigename/Farbe
@@ -176,7 +186,6 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Belohnungskatalog für Ämtchen-Punkte
- Web-Push für Termine und Chat (Infrastruktur aus Phase 5 vorhanden)
- Chat: Nachrichten bearbeiten, Antworten, Suche, Aufräumen alter Nachrichten/Bilder
- CalDAV-Server bzw. Microsoft-Graph-Synchronisation (ADR-0017)
- Kalender: Stunden-Raster mit Drag & Drop, „dieser und folgende“, Erinnerungen per Web-Push für Termine
- Rezeptdatenbank
- Geburtstage und Jahrestage
@@ -185,4 +194,5 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- KI: optionale Online-Suche in Prompts (Ja/Nein, Opt-in je Anbieter und je Anfrage; nur für Anbieter, die das unterstützen, z. B. Claude-Web-Suche, Perplexity; Datenschutzhinweis)
- KI: vollständiges Protokoll aller abgeschickten Prompts samt Antwort in der Datenbank (Admin-Ansicht, Aufbewahrungsdauer, Löschen; Inhalte des privaten KI-Chats nur auf ausdrückliche Admin-Einstellung)
- Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“
- Kalender: Freigabe und Zwei-Wege-Synchronisation mit iOS/macOS (CalDAV-Server, ADR-0017) – Outlook über Microsoft Graph als eigener Folgeschritt
- Outlook-Synchronisation über Microsoft Graph (ADR-0017): Zwei-Wege-Abgleich mit Outlook/Microsoft 365 (OAuth, Delta-Abfragen, Webhooks); Outlook kann kein CalDAV, bis dahin nur lesend per ICS-Abo
- CalDAV: Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5), VALARM/Teilnehmer erhalten, Admin-Übersicht und Widerruf der App-Passwörter aller Benutzer
+67 -1
View File
@@ -1,6 +1,6 @@
# ADR-0017: Bidirektionale Synchronisation über CalDAV / Microsoft Graph – später
* Status: vorgeschlagen (Backlog)
* Status: CalDAV umgesetzt (Phase 12c, siehe Nachtrag); Microsoft Graph vorgeschlagen (Backlog)
* Datum: 2026-10-10
## Kontext
@@ -26,3 +26,69 @@ CalDAV nur über Zusatzprogramme – dafür bleibt das ICS-Abo.
## Offene Fragen
App-Passwörter (Datenmodell, Verwaltung), Konfliktbehandlung (If-Match/412), Umgang mit nicht unterstützten RRULE-Teilen aus Clients.
## Nachtrag (Phase 12c): CalDAV umgesetzt, Outlook bleibt offen
* Status: **CalDAV angenommen und umgesetzt**; Microsoft Graph bleibt Backlog (Datum: 2026-10-10).
### Umfang
Ein minimaler, standardkonformer CalDAV-Server in der Api (`Features/CalDav/`), erreichbar unter `/caldav/` (BFF/YARP leitet `/caldav` und
`/.well-known/caldav` an die Api; letzteres antwortet mit 301 auf `/caldav/`, RFC 6764). Gegen die Protokolle getestet, nicht gegen alle Clients (siehe unten).
| Pfad | Bedeutung |
|---|---|
| `/caldav/` | Wurzel (`current-user-principal`) |
| `/caldav/principals/{userId}/` | Principal (nur der eigene; `calendar-home-set`) |
| `/caldav/calendars/` | Calendar-Home |
| `/caldav/calendars/{calendarId}/` | Ein Familienkalender (`resourcetype` calendar, nur VEVENT, `getctag`, `sync-token`, `calendar-color`, `current-user-privilege-set`) |
| `/caldav/calendars/{calendarId}/{uid}.ics` | Ein Termin **samt aller Ausnahmen** (Serie + RECURRENCE-ID-Überschreibungen = ein iCalendar-Objekt je UID) |
* Methoden: `OPTIONS` (`DAV: 1, calendar-access`), `PROPFIND` (Depth 0/1; `infinity` → 403), `PROPPATCH` (je Eigenschaft 403, damit Clients nicht hängen),
`REPORT` (`calendar-query` mit time-range inkl. Serien-Expansion und prop-filter UID/SUMMARY/LOCATION/DESCRIPTION, `calendar-multiget`, `sync-collection`),
`GET`/`HEAD`/`PUT`/`DELETE` mit `If-Match`/`If-None-Match` (412 bei Konflikt). `MKCALENDAR`/`MKCOL`/`MOVE`/`COPY` → 403 (Kalender verwaltet die Administration).
* **ETag** je Termin = Hash aus Id, `RowVersion` und `UpdatedAt` aller Zeilen der UID (Überschreibungen zählen mit). **CTag/Sync-Token** je Kalender =
jüngste Änderung (`UpdatedAt`) aller Zeilen inklusive gelöschter (Soft Delete setzt `UpdatedAt`): `urn:familyplanner:sync:{ticks}`. Zustandslos, keine Token-Tabelle;
`sync-collection` liefert Änderungen strikt „neuer als Token“ und gelöschte Serien als 404.
* **PUT** nutzt den vorhandenen `IcsImporter`; Änderungen gleichen Zeilen über (UID, RECURRENCE-ID) ab (Ids, Ersteller, Personen-Tags bleiben erhalten). Neue Termine
erhalten den Anleger als Personen-Tag. Die Antwort enthält bewusst **keinen ETag** (die gespeicherte Fassung ist normalisiert, RFC 4791 §5.3.4); Clients laden per GET nach.
Die CalDAV-Ausgabe (`IcsBuilder` mit `resourceMode`) enthält weder die „Für: …“-Zeile noch Feed-Kopfzeilen/Kategorien, damit Notizen nicht bei jedem Speichern
durch einen Client wachsen.
* **Bekannte Verluste (bewusst):** VALARM (Erinnerungen), ATTENDEE/ORGANIZER, Anhänge, Kategorien und X-Eigenschaften werden nicht gespeichert; Personen-Tags
sind in CalDAV unsichtbar (bleiben bei Änderungen aber erhalten). Nicht unterstützte Wiederholungen (außerhalb der RRULE-Teilmenge, ADR-0014) werden mit
403 `valid-calendar-object-resource` **abgelehnt**, statt sie still zu Einzelterminen zu machen (für den Client wäre das Datenverlust); VTODO/VJOURNAL/VFREEBUSY → 403
`supported-calendar-component`. Der Dateiname muss `<UID>.ics` entsprechen (so benennen iOS, Thunderbird und DAVx5). UIDs mit `/` überstehen den Proxy nur dank
einer Heuristik (YARP kodiert `%2F` doppelt); ein wörtliches `%2F` in einer UID ist nicht darstellbar.
* **Offene Randfälle:** Verschiebt jemand in der Web-Oberfläche einen Termin in einen anderen Kalender, sieht der alte CalDAV-Kalender kein Löschen
(bis zur nächsten Vollsynchronisation). Der Änderungszeitpunkt wird vor dem Commit gesetzt – eine parallel committende Änderung mit knapp früherem Zeitstempel kann
einem Sync-Token theoretisch entgehen (bei Familiengröße vernachlässigbar, ein Neu-Sync behebt es).
### Rechte
Es gelten dieselben Regeln wie in der Web-Oberfläche (ADR-0015), **bei jeder Anfrage serverseitig**: `CalendarAccessService` ermittelt je Kalender und Benutzer `None/Read/WriteOwn/Write`.
Kalender ohne Lesezugriff sind unsichtbar (404, nicht in Listen). `PUT` neuer Termine braucht `CanCreate`, `PUT`/`DELETE` bestehender `CanModify` (Kinder: nur eigene, bei
Serien zählt der Ersteller der Serie; Gäste: nie). Der Server meldet `current-user-privilege-set` je Kalender **und je Termin**, damit Clients Schreibschutz anzeigen.
Fremde Principals sind nicht erreichbar. Änderungen lösen `CalendarChanged` für die Web-Oberfläche aus.
### Sicherheit (App-Passwörter, Basic nur über HTTPS)
* Authentifizierung per **HTTP Basic mit App-Passwort** (Entität `CalDavCredential`, Migration `Phase12cCalDav`): je Benutzer mehrere, mit Bezeichnung, einmalig angezeigt
(`xxxx-xxxx-xxxx-xxxx-xxxx`, ca. 99 Bit Zufall), nur als SHA-256 gespeichert (hohe Entropie, daher genügt ein schneller Hash; Basic sendet das Geheimnis bei jeder Anfrage,
PBKDF2 wäre dort untragbar), jederzeit widerrufbar, „zuletzt benutzt“ (höchstens alle 5 Minuten geschrieben) samt User-Agent. Höchstens 10 aktive je Benutzer.
Erstellen und Widerrufen stehen im Audit-Log (ohne Geheimnis).
* **Niemals das normale Passwort:** Das Schema akzeptiert ausschließlich App-Passwörter (ein falsches Format wird gar nicht erst in der Datenbank gesucht). Das Schema `CalDavBasic` ist
**nicht Standard** und nur an die Policy `CalDav` gebunden: Ein App-Passwort öffnet keine `/api`-Endpunkte, ein Login-Cookie öffnet `/caldav` nicht (kein CSRF-Risiko).
Der Benutzername muss zum App-Passwort gehören; deaktivierte, abgelaufene und gelöschte Konten sowie Konten mit offenem Passwortwechsel werden abgewiesen.
* **HTTPS-Pflicht:** `CalDav:RequireHttps` (Standard `true`; in `appsettings.Development.json` aus). Hinter Caddy/YARP kommt das Schema über `X-Forwarded-Proto`. Ohne HTTPS antwortet
die Api 403 – das Geheimnis wird dann nicht geprüft. Für ein reines Heimnetz ohne Zertifikat kann `CalDav__RequireHttps=false` gesetzt werden (Risiko: Klartext-Passwort im LAN; die Oberfläche warnt).
* **Schutz vor Raten:** Fehlversuche werden je IP (30) und je IP + Benutzername (8) in 10 Minuten gezählt, danach 429 mit `Retry-After` (bewusst nicht je Benutzername allein,
damit niemand Dritte aussperren kann). Zusätzlich ein großzügiges Anfragelimit je IP (`RateLimiting__CalDavPermitLimit`, 600/min), weil iOS beim ersten Abgleich viele Anfragen stellt.
* Anfragekörper sind auf 1 MB begrenzt; XML wird ohne DTD und externe Entitäten gelesen.
### Outlook: ehrlich
Klassisches Outlook (Windows) und das neue Outlook sprechen **kein CalDAV**; es gibt keine eingebaute Möglichkeit, einen CalDAV-Kalender zwei-wege einzubinden
(nur Zusatzprogramme wie „CalDav Synchronizer“, die hier weder getestet noch Teil der Lieferung sind). Eine echte Zwei-Wege-Synchronisation mit Outlook/Microsoft 365 wäre nur über
**Microsoft Graph** möglich (OAuth/App-Registrierung, Delta-Abfragen, Webhooks, Cloud-Konto) – das bleibt ein eigener Folgeschritt im Backlog („Outlook-Synchronisation über Microsoft Graph“).
Bis dahin kann Outlook die Familienkalender über das bestehende **ICS-Abo nur lesend** einbinden (ADR-0016; Outlook aktualisiert Abos nur alle paar Stunden).
### Getestet / nicht getestet
Automatisiert: Unit (Pfade, ETag, Gruppierung, Sync-Token/Delta, XML, calendar-query, PUT-Prüfung, Export-Roundtrip, Drosselung, App-Passwort-Format) und Integration über den BFF
(Entdeckung, Round-Trip, 412, Serien mit Ausnahmen, Sync/multiget/query, Rechte für Kind/Gast/versteckte und schreibgeschützte Kalender, falsche/widerrufene Zugangsdaten, Drosselung).
**Nicht** mit echten Clients ausprobiert: iOS/macOS Kalender, Thunderbird, DAVx5/Android. Die Eigenheiten dieser Clients sind aus der Protokollkenntnis umgesetzt;
Rückmeldungen aus dem Praxiseinsatz können kleine Anpassungen nötig machen.