feat: add CalDAV source provider

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 <no-reply@ona.com>
This commit is contained in:
2026-04-11 15:29:29 +00:00
parent 98ce546eff
commit 21a078ac40
3 changed files with 140 additions and 0 deletions

View File

@@ -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")
})
})

View File

@@ -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<CalDavSource> {
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,
})
}
}

View File

@@ -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: {