From 400055ab8cc3e5e91f0d6cbfd0d0c07d5ea1dda0 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 11 Apr 2026 16:34:11 +0100 Subject: [PATCH] feat: add CalDAV source provider (#111) Wire CalDavSourceProvider into the backend to support CalDAV calendar sources (e.g. iCloud) with basic auth. Config accepts serverUrl, username, lookAheadDays, and timeZone. Credentials (app-specific password) are stored encrypted via the existing credential storage infrastructure. Co-authored-by: Ona --- .../aelis-backend/src/caldav/provider.test.ts | 85 +++++++++++++++++++ apps/aelis-backend/src/caldav/provider.ts | 53 ++++++++++++ apps/aelis-backend/src/server.ts | 2 + 3 files changed, 140 insertions(+) create mode 100644 apps/aelis-backend/src/caldav/provider.test.ts create mode 100644 apps/aelis-backend/src/caldav/provider.ts diff --git a/apps/aelis-backend/src/caldav/provider.test.ts b/apps/aelis-backend/src/caldav/provider.test.ts new file mode 100644 index 0000000..0c3891a --- /dev/null +++ b/apps/aelis-backend/src/caldav/provider.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" + +import { CalDavSourceProvider } from "./provider.ts" + +describe("CalDavSourceProvider", () => { + const provider = new CalDavSourceProvider() + + test("sourceId is aelis.caldav", () => { + expect(provider.sourceId).toBe("aelis.caldav") + }) + + test("throws when credentials are null", async () => { + const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" } + await expect(provider.feedSourceForUser("user-1", config, null)).rejects.toThrow( + "No CalDAV credentials configured", + ) + }) + + test("throws when credentials are missing password", async () => { + const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" } + await expect(provider.feedSourceForUser("user-1", config, {})).rejects.toThrow( + "password must be a string", + ) + }) + + test("throws when config is missing serverUrl", async () => { + const credentials = { password: "app-specific-password" } + await expect( + provider.feedSourceForUser("user-1", { username: "user@icloud.com" }, credentials), + ).rejects.toThrow("Invalid CalDAV config") + }) + + test("throws when config is missing username", async () => { + const credentials = { password: "app-specific-password" } + await expect( + provider.feedSourceForUser("user-1", { serverUrl: "https://caldav.icloud.com" }, credentials), + ).rejects.toThrow("Invalid CalDAV config") + }) + + test("throws when config has extra keys", async () => { + const config = { + serverUrl: "https://caldav.icloud.com", + username: "user@icloud.com", + extra: true, + } + const credentials = { password: "app-specific-password" } + await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow( + "Invalid CalDAV config", + ) + }) + + test("throws when credentials have extra keys", async () => { + const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" } + const credentials = { password: "app-specific-password", extra: true } + await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow( + "extra must be removed", + ) + }) + + test("returns CalDavSource with valid config and credentials", async () => { + const config = { + serverUrl: "https://caldav.icloud.com", + username: "user@icloud.com", + lookAheadDays: 3, + timeZone: "Europe/London", + } + const credentials = { password: "app-specific-password" } + + const source = await provider.feedSourceForUser("user-1", config, credentials) + expect(source).toBeDefined() + expect(source.id).toBe("aelis.caldav") + }) + + test("returns CalDavSource with minimal config", async () => { + const config = { + serverUrl: "https://caldav.icloud.com", + username: "user@icloud.com", + } + const credentials = { password: "app-specific-password" } + + const source = await provider.feedSourceForUser("user-1", config, credentials) + expect(source).toBeDefined() + expect(source.id).toBe("aelis.caldav") + }) +}) diff --git a/apps/aelis-backend/src/caldav/provider.ts b/apps/aelis-backend/src/caldav/provider.ts new file mode 100644 index 0000000..5ad5195 --- /dev/null +++ b/apps/aelis-backend/src/caldav/provider.ts @@ -0,0 +1,53 @@ +import { CalDavSource } from "@aelis/source-caldav" +import { type } from "arktype" + +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +import { InvalidSourceCredentialsError } from "../sources/errors.ts" + +const caldavConfig = type({ + "+": "reject", + serverUrl: "string", + username: "string", + "lookAheadDays?": "number", + "timeZone?": "string", +}) + +const caldavCredentials = type({ + "+": "reject", + password: "string", +}) + +export class CalDavSourceProvider implements FeedSourceProvider { + readonly sourceId = "aelis.caldav" + readonly configSchema = caldavConfig + + async feedSourceForUser( + _userId: string, + config: unknown, + credentials: unknown, + ): Promise { + const parsed = caldavConfig(config) + if (parsed instanceof type.errors) { + throw new Error(`Invalid CalDAV config: ${parsed.summary}`) + } + + if (!credentials) { + throw new InvalidSourceCredentialsError("aelis.caldav", "No CalDAV credentials configured") + } + + const creds = caldavCredentials(credentials) + if (creds instanceof type.errors) { + throw new InvalidSourceCredentialsError("aelis.caldav", creds.summary) + } + + return new CalDavSource({ + serverUrl: parsed.serverUrl, + authMethod: "basic", + username: parsed.username, + password: creds.password, + lookAheadDays: parsed.lookAheadDays, + timeZone: parsed.timeZone, + }) + } +} diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index 92c50dc..5b6de23 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -6,6 +6,7 @@ import { createRequireAdmin } from "./auth/admin-middleware.ts" import { registerAuthHandlers } from "./auth/http.ts" import { createAuth } from "./auth/index.ts" import { createRequireSession } from "./auth/session-middleware.ts" +import { CalDavSourceProvider } from "./caldav/provider.ts" import { createDatabase } from "./db/index.ts" import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" @@ -48,6 +49,7 @@ function main() { const sessionManager = new UserSessionManager({ db, providers: [ + new CalDavSourceProvider(), new LocationSourceProvider(), new WeatherSourceProvider({ credentials: {