- Remove frontend artifacts from 05-03 must_haves (belong to 05-04)
- Remove trailing XML generation artifacts from 05-04 and 05-05
- Update ROADMAP Phase 5: 5 plans across 4 waves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AES-256-GCM key required to encrypt calendar credentials at rest
name
source
CALENDAR_ENCRYPTION_KEY
Generate a 32-byte hex key: openssl rand -hex 32 — add to apps/api .env and docker-compose api environment
truths
artifacts
key_links
User can configure CalDAV, Exchange, and ICS calendar sources in Settings > Dashboard > Kalender
User can toggle which calendar sources are visible in the widget
Calendar widget shows upcoming events aggregated from selected sources
Calendar credentials are encrypted at rest and never returned in GET responses
path
provides
contains
apps/api/prisma/schema.prisma
CalendarSource model
model CalendarSource
path
provides
exports
apps/api/src/calendar/calendar.service.ts
Event aggregation across provider types
CalendarService
path
provides
apps/api/src/calendar/providers/ics.provider.ts
ICS event fetch + parse
from
to
via
pattern
apps/api/src/calendar/calendar.service.ts
prisma.calendarSource
user-scoped query with credential decryption
prisma.calendarSource
from
to
via
pattern
apps/api/src/calendar/calendar.service.ts
ICSProvider/CalDAVProvider/ExchangeProvider
provider dispatch by source.type
type === 'ics'
Deliver the calendar vertical slice end-to-end: a user configures one or more calendar sources (CalDAV via tsdav, ICS via node-ical, Exchange via ews/Graph) in Settings > Dashboard > Kalender, toggles which sources are visible, and the Calendar widget on the dashboard shows upcoming events aggregated and normalized across all visible sources. Credentials are encrypted at rest (AES-256-GCM) and never leave the backend.
Purpose: Implements CAL-01 (source integration), CAL-02 (visibility selection), CAL-03 (event previews for selected sources), and DASH-05 (calendar widget). This is the heaviest slice — calendar protocols carry the highest hidden complexity per RESEARCH.
Output: Working calendar widget backed by a multi-protocol aggregation service and a source-management settings page.
<artifacts_this_phase_produces>
Symbols created by THIS plan (exclude from drift verification — they are new):
Prisma models:CalendarSourceNestJS:CalendarModule, CalendarController, CalendarService, CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider, CreateCalendarSourceDto, UpdateCalendarSourceDto, CalendarEventsQueryDtoInterfaces:CalendarEvent, CalendarProviderAPI endpoints:GET /api/calendar/sources, POST /api/calendar/sources, PATCH /api/calendar/sources/:id, DELETE /api/calendar/sources/:id, POST /api/calendar/sources/:id/test, GET /api/calendar/eventsnpm:tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-clientenv var:CALENDAR_ENCRYPTION_KEY
</artifacts_this_phase_produces>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-dashboard-calendar/05-CONTEXT.md
@.planning/phases/05-dashboard-calendar/05-RESEARCH.md
@.planning/phases/05-dashboard-calendar/05-PATTERNS.md
@.planning/phases/05-dashboard-calendar/05-UI-SPEC.md
@.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md
@.planning/phases/05-dashboard-calendar/05-02-SUMMARY.md
Task 1: Calendar backend — model, crypto, source CRUD module
apps/api/prisma/schema.prisma, apps/api/src/app.module.ts, apps/api/src/calendar/calendar.module.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/crypto.service.ts, apps/api/src/calendar/dto/create-calendar-source.dto.ts, apps/api/src/calendar/dto/update-calendar-source.dto.ts, apps/api/src/calendar/dto/calendar-events-query.dto.ts, apps/api/package.json
- apps/api/prisma/schema.prisma (CalendarSource model follows existing conventions; see CalendarSource example in RESEARCH lines 562-581)
- apps/api/src/module-registry/module-registry.module.ts (module pattern)
- apps/api/src/module-registry/module-registry.controller.ts (userId/tenant extraction)
- apps/api/src/module-registry/module-registry.service.ts (PrismaService injection + CRUD)
- apps/api/src/domaincheck/dto/check-domain.dto.ts (DTO pattern)
- apps/api/src/app.module.ts (register CalendarModule)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 364-412 (anti-patterns: never fetch calendar in browser, encrypt credentials, never return passwords), lines 673-681 (encryption recommendation), lines 743-761 (Security Domain: SSRF, credential exposure)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 161-166 (source list + form fields)
Install calendar libs: `cd apps/api && pnpm add tsdav@2.2.2 node-ical@0.26.1 ews-javascript-api@0.15.3 @microsoft/microsoft-graph-client@3.0.7` (all Approved in RESEARCH Legitimacy Audit — no checkpoint).
Add Prisma model `CalendarSource`: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `name String`, `type String` ('caldav'|'ics'|'exchange'), `exchangeMode String?` ('ews'|'graph' — only for exchange, per RESEARCH open question 2), `url String`, `username String?`, `encryptedPassword String?` (AES-256-GCM ciphertext), `color String? @default("#3B82F6")`, `isVisible Boolean @default(true)`, `syncIntervalMin Int @default(15)`, `lastSyncAt DateTime?`, `lastSyncError String?`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([userId])`, `@@index([tenantId])`. Source config is per-user (D-09), not per-tenant.
Create `crypto.service.ts` (`CalendarCryptoService`, injectable): AES-256-GCM using `CALENDAR_ENCRYPTION_KEY` (32-byte hex) from ConfigService. `encrypt(plaintext)` returns `iv:authTag:ciphertext` (hex-joined); `decrypt(stored)` reverses it. Throw a clear error at startup if the key is missing/wrong length.
Create CalendarModule (controllers: [CalendarController], providers: [CalendarService, CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider]); register in app.module.ts imports. Providers added in Task 2 — declare them in providers array now (stub provider files created in Task 2, but module references them; create minimal stubs here if needed so the module compiles, or sequence so Task 2 fills bodies).
CalendarController `@Controller('calendar')`: `@Get('sources')` (returns sources WITHOUT password fields — use Prisma `select` excluding encryptedPassword, Pitfall 3), `@Post('sources')`, `@Patch('sources/:id')`, `@Delete('sources/:id')`, `@Post('sources/:id/test')` (testConnection), `@Get('events')` (aggregated events — implemented in Task 3). Every handler extracts userId + tenantId; all source mutations verify userId ownership.
CalendarService (this task: source CRUD only): inject PrismaService + CalendarCryptoService. `getSources(userId)` → findMany with `select` that NEVER includes encryptedPassword (return a boolean `hasCredentials` instead). `addSource(userId, tenantId, dto)` → encrypt dto.password into encryptedPassword before create. `updateSource(id, userId, dto)` → ownership check, re-encrypt if password provided. `deleteSource(id, userId)` → ownership check + delete.
DTOs: CreateCalendarSourceDto — `@IsString() @IsNotEmpty() name`, `@IsIn(['caldav','ics','exchange']) type`, `@IsUrl({ protocols: ['https'], require_protocol: true }) url` (https-only — SSRF mitigation, Security Domain), `@IsOptional() @IsString() username`, `@IsOptional() @IsString() password`, `@IsOptional() @IsIn(['ews','graph']) exchangeMode`, `@IsOptional() @IsHexColor() color`. UpdateCalendarSourceDto — all optional incl. `@IsOptional() @IsBoolean() isVisible` (CAL-02 toggle). CalendarEventsQueryDto — `@IsOptional() @IsDateString() from`, `@IsOptional() @IsDateString() to`.
SSRF guard: in addSource/test, reject URLs resolving to private IP ranges (10.x, 192.168.x, 127.x, 169.254.x) in addition to https-only scheme.
cd apps/api && npx prisma validate && npx tsc --noEmit
- apps/api/package.json contains tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client
- schema.prisma contains `model CalendarSource` with `encryptedPassword` and `isVisible`
- calendar.controller.ts GET /sources uses Prisma `select` that excludes encryptedPassword (grep: no `encryptedPassword: true` in the sources select)
- crypto.service.ts uses `aes-256-gcm`
- create-calendar-source.dto.ts restricts url to https (`protocols: ['https']`)
- app.module.ts imports CalendarModule
- `npx prisma validate` and `npx tsc --noEmit` exit 0
Calendar source CRUD with encrypted credentials, https-only URLs, no password leakage in GET responses.
Task 2: Calendar providers (CalDAV, ICS, Exchange)
apps/api/src/calendar/providers/caldav.provider.ts, apps/api/src/calendar/providers/ics.provider.ts, apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/calendar/calendar.service.ts (from Task 1 — providers are injected here; match the CalendarProvider interface)
- apps/api/src/calendar/crypto.service.ts (decrypt credentials before use)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 287-310 (CalendarEvent + CalendarProvider interface), lines 96-101 (library purposes), Assumptions A2/A3/A6 (date-range filter, exchange OAuth, recurring expansion)
- .planning/phases/05-dashboard-calendar/05-PATTERNS.md lines 353-361 (no-analog — use RESEARCH library patterns)
Define a shared `CalendarProvider` interface (in calendar.service.ts or a types file): `fetchEvents(source, from, to): Promise` and `testConnection(source): Promise`. `CalendarEvent { id, sourceId, title, start: Date, end: Date, allDay: boolean, location?, description? }`.
`ics.provider.ts` (ICSProvider, injectable) — the simplest, implement fully: HTTPS GET the .ics URL (timeout ~8s, abort on timeout — Pitfall 4), parse with node-ical `async.fromURL` or `sync.parseICS`. Filter VEVENTs to the [from,to] window. Expand recurring events via node-ical RRULE support (Assumption A6 — if node-ical's expansion is insufficient, note the ical.js fallback but do not block). Map to CalendarEvent. testConnection: fetch + parse, return true if at least the request succeeds.
`caldav.provider.ts` (CalDAVProvider, injectable): use tsdav `createDAVClient` with Basic auth (username + decrypted password). Login, fetch calendars, `fetchCalendarObjects` with the time-range filter (Assumption A2 — pass start/end so only the window is fetched). Parse returned iCal data with node-ical, map to CalendarEvent. testConnection: attempt login + list calendars.
`exchange.provider.ts` (ExchangeProvider, injectable): dispatch on `source.exchangeMode`. For 'graph' use @microsoft/microsoft-graph-client to query `/me/calendarView` with start/end (Exchange Online, RESEARCH State of the Art prefers Graph). For 'ews' use ews-javascript-api FindAppointments over a CalendarView (on-premise). Map to CalendarEvent. Wrap both in try/catch; on auth failure surface a generic error (no credential details — Security V7). If Exchange integration cannot be fully realized for a given mode, return an empty array and set lastSyncError rather than throwing (graceful degradation; D-08 requires the source TYPE to be configurable and attempted — the widget must not crash).
All providers: never log decrypted credentials; respect per-request timeouts.
cd apps/api && npx tsc --noEmit
- ics.provider.ts imports node-ical and filters events by date window
- caldav.provider.ts imports tsdav and passes a time-range to fetchCalendarObjects
- exchange.provider.ts branches on exchangeMode ('graph' vs 'ews')
- no provider logs decrypted passwords (grep: no `console.log` of password/credential vars)
- all three implement fetchEvents + testConnection returning the CalendarEvent shape
- `npx tsc --noEmit` exits 0
Three providers fetch + normalize events; Exchange degrades gracefully; no credential leakage.
Task 3: Event aggregation + caching backend
apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts
- apps/api/src/calendar/calendar.service.ts (from Task 1/2 — add aggregateEvents)
- apps/api/src/calendar/providers/*.provider.ts (from Task 2 — dispatch by type)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 408-413 (caching with TTL Pitfall 4)
Add `aggregateEvents(userId, from, to)` to CalendarService: load the user's `isVisible: true` sources (CAL-02/CAL-03), dispatch each to its provider by `source.type` (ics/caldav/exchange), decrypt credentials per source, fetch in parallel (`Promise.allSettled` so one failing source doesn't break others — set lastSyncError on failures), merge + sort by start ascending, return normalized CalendarEvent[] including each event's source color. Cache results per user in an in-memory Map with a 5-minute TTL (Pitfall 4) — serve cached immediately, refresh in background. Implement `GET /calendar/events` in the controller using CalendarEventsQueryDto (default window: now → now+30 days). Implement `POST /calendar/sources/:id/test` → provider.testConnection, update lastSyncAt/lastSyncError.
cd apps/api && npx tsc --noEmit
- calendar.service.ts aggregateEvents uses `Promise.allSettled` and filters `isVisible`
- calendar.service.ts caches events with a TTL (grep: a Map + timestamp/expiry check)
- calendar.controller.ts contains `@Get('events')` and `@Post('sources/:id/test')`
- `npx tsc --noEmit` exits 0
Event aggregation across providers with caching, test-connection endpoint.
Task 4: [BLOCKING] Prisma schema push + encryption key check
apps/api/prisma/schema.prisma
- apps/api/prisma/schema.prisma (CalendarSource model from Task 1)
- apps/api/src/calendar/crypto.service.ts (CALENDAR_ENCRYPTION_KEY consumer)
Ensure `CALENDAR_ENCRYPTION_KEY` (32-byte hex, generate via `openssl rand -hex 32`) is present in apps/api `.env` and the docker-compose api service environment so the crypto service can start. Then push the schema to the running PostgreSQL container: `npx prisma db push` from apps/api, then `npx prisma generate`. MANDATORY — type checks pass without the push (false-positive). Only the new CalendarSource table is added; if data loss is reported, STOP and flag for manual review rather than passing `--accept-data-loss`.
cd apps/api && test -n "$(grep -s CALENDAR_ENCRYPTION_KEY .env)" && npx prisma db push --skip-generate && npx prisma generate
- apps/api/.env contains CALENDAR_ENCRYPTION_KEY
- `npx prisma db push` exits 0 and reports in sync on a second run
- live DB contains the CalendarSource table
- `npx prisma generate` exits 0
Live schema includes CalendarSource; encryption key configured; client regenerated.
<threat_model>
Trust Boundaries
Boundary
Description
Browser → Calendar API
User submits source config incl. credentials
API → External calendar servers
Backend fetches events from user-supplied URLs
API → PostgreSQL
Encrypted credentials stored at rest
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-05-09
Information Disclosure
GET /calendar/sources
mitigate
Prisma select excludes encryptedPassword; return hasCredentials boolean only (Pitfall 3)
T-05-10
Information Disclosure
CalendarSource at rest
mitigate
AES-256-GCM encryption via CalendarCryptoService + CALENDAR_ENCRYPTION_KEY