@pellux/goodvibes-tui 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
  6. package/src/daemon/handlers/calendar/ics.ts +556 -0
  7. package/src/daemon/handlers/calendar/index.ts +316 -0
  8. package/src/daemon/handlers/context.ts +29 -0
  9. package/src/daemon/handlers/contracts.ts +77 -0
  10. package/src/daemon/handlers/credentials.ts +129 -0
  11. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  12. package/src/daemon/handlers/drafts/index.ts +17 -0
  13. package/src/daemon/handlers/drafts/register.ts +331 -0
  14. package/src/daemon/handlers/email/config.ts +164 -0
  15. package/src/daemon/handlers/email/imap-connector.ts +441 -0
  16. package/src/daemon/handlers/email/imap-parsing.ts +499 -0
  17. package/src/daemon/handlers/email/index.ts +43 -0
  18. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  19. package/src/daemon/handlers/email/runtime.ts +140 -0
  20. package/src/daemon/handlers/email/smtp-connector.ts +557 -0
  21. package/src/daemon/handlers/email/validation.ts +147 -0
  22. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  23. package/src/daemon/handlers/errors.ts +18 -0
  24. package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
  25. package/src/daemon/handlers/inbox/index.ts +210 -0
  26. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  27. package/src/daemon/handlers/inbox/poller.ts +155 -0
  28. package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
  29. package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
  30. package/src/daemon/handlers/inbox/providers/email.ts +151 -0
  31. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  32. package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
  33. package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
  34. package/src/daemon/handlers/index.ts +107 -0
  35. package/src/daemon/handlers/register.ts +161 -0
  36. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
  37. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  38. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  39. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  40. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  41. package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
  42. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  43. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  44. package/src/daemon/handlers/remote/index.ts +119 -0
  45. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  46. package/src/daemon/handlers/remote/service.ts +191 -0
  47. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  48. package/src/daemon/handlers/routing/index.ts +261 -0
  49. package/src/daemon/handlers/routing/route-store.ts +319 -0
  50. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  51. package/src/daemon/handlers/sqlite-store.ts +124 -0
  52. package/src/daemon/handlers/triage/index.ts +57 -0
  53. package/src/daemon/handlers/triage/integration.ts +212 -0
  54. package/src/daemon/handlers/triage/pipeline.ts +273 -0
  55. package/src/daemon/handlers/triage/scorer.ts +287 -0
  56. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  57. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  58. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  59. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  60. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  61. package/src/daemon/handlers/triage/types.ts +50 -0
  62. package/src/runtime/services.ts +48 -0
  63. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.33.38",
102
+ "@pellux/goodvibes-sdk": "0.34.0",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -0,0 +1,661 @@
1
+ // CalDAV client for the calendar handler surface.
2
+ //
3
+ // Authenticates to the user's CalDAV endpoint using credentials resolved from
4
+ // the daemon credential store (config keys under `surfaces.calendar.*`). It
5
+ // implements the CalDAV/WebDAV verbs the gateway methods need:
6
+ // - REPORT (calendar-query) -> list / get events
7
+ // - PUT -> create / import events
8
+ // - REPORT (multiget) / GET -> export a collection
9
+ // - PROPFIND -> discover calendar collections
10
+ //
11
+ // SECURITY POSTURE:
12
+ // * The authenticated CalDAV base URL and credentials NEVER leave this module.
13
+ // * `calendarId` is a LOGICAL identifier. It is mapped internally to a
14
+ // collection path; the authenticated absolute URL is never returned to or
15
+ // accepted from callers.
16
+ // * Event hrefs are returned to callers as opaque relative identifiers, never
17
+ // as fully-qualified authenticated URLs.
18
+
19
+ import { HandlerError } from '../errors.ts';
20
+ import type { HandlerContext } from '../context.ts';
21
+ import type { DaemonCredentialStore } from '../credentials.ts';
22
+ import {
23
+ generateCalendar,
24
+ generateICS,
25
+ parseICS,
26
+ type GenerateICalInput,
27
+ type ParsedICalEvent,
28
+ } from './ics.ts';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Configuration
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const CFG_URL = 'surfaces.calendar.caldavUrl';
35
+ const CFG_USER = 'surfaces.calendar.caldavUser';
36
+ const CFG_PASSWORD = 'surfaces.calendar.caldavPassword';
37
+ const CFG_DEFAULT_CALENDAR = 'surfaces.calendar.defaultCalendarId';
38
+ const CFG_CALENDARS = 'surfaces.calendar.calendars';
39
+
40
+ export interface CalDavConfig {
41
+ baseUrl: string;
42
+ username: string;
43
+ password: string;
44
+ defaultCalendarId: string;
45
+ /** logical calendarId -> collection path (relative to baseUrl host). */
46
+ collectionMap: Record<string, string>;
47
+ }
48
+
49
+ export interface CalDavEvent extends ParsedICalEvent {
50
+ /** Opaque, host-relative href identifying the resource (never absolute/authenticated). */
51
+ href: string;
52
+ /** Logical calendar id this event belongs to. */
53
+ calendarId: string;
54
+ }
55
+
56
+ export interface ListEventsOptions {
57
+ calendarId?: string;
58
+ from?: string;
59
+ to?: string;
60
+ limit?: number;
61
+ }
62
+
63
+ export interface CreateEventInput {
64
+ title: string;
65
+ start: string;
66
+ end: string;
67
+ description?: string;
68
+ attendees?: string[];
69
+ location?: string;
70
+ calendarId?: string;
71
+ }
72
+
73
+ export interface CreatedEvent {
74
+ eventId: string; // opaque href or UID
75
+ uid: string;
76
+ createdAt: string;
77
+ }
78
+
79
+ export interface ImportResult {
80
+ imported: number;
81
+ eventIds: string[];
82
+ errors: string[];
83
+ }
84
+
85
+ export interface ExportResult {
86
+ icsContent: string;
87
+ eventCount: number;
88
+ }
89
+
90
+ /** Minimal fetch contract so the client is testable with an injected fetch. */
91
+ export type FetchLike = (
92
+ input: string,
93
+ init?: {
94
+ method?: string;
95
+ headers?: Record<string, string>;
96
+ body?: string;
97
+ },
98
+ ) => Promise<{
99
+ ok: boolean;
100
+ status: number;
101
+ statusText: string;
102
+ headers: { get(name: string): string | null };
103
+ text(): Promise<string>;
104
+ }>;
105
+
106
+ export interface CalDavClient {
107
+ listCalendars(): Promise<Array<{ calendarId: string; displayName: string }>>;
108
+ listEvents(opts: ListEventsOptions): Promise<CalDavEvent[]>;
109
+ getEvent(eventId: string, calendarId?: string): Promise<CalDavEvent | null>;
110
+ createEvent(input: CreateEventInput): Promise<CreatedEvent>;
111
+ importIcs(icsContent: string, calendarId?: string): Promise<ImportResult>;
112
+ exportIcs(opts: { calendarId?: string; from?: string; to?: string }): Promise<ExportResult>;
113
+ }
114
+
115
+ /** Subset of the handler context the CalDAV config resolver needs. */
116
+ export type CalDavConfigContext = Pick<HandlerContext, 'configManager' | 'credentials'>;
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Config resolution
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function readConfigString(
123
+ ctx: Pick<HandlerContext, 'configManager'>,
124
+ key: string,
125
+ ): string | undefined {
126
+ const value = ctx.configManager.get(key as never);
127
+ if (value === undefined || value === null) return undefined;
128
+ const text = String(value).trim();
129
+ return text.length > 0 ? text : undefined;
130
+ }
131
+
132
+ function parseCollectionMap(raw: string | undefined): Record<string, string> {
133
+ if (!raw) return {};
134
+ try {
135
+ const parsed = JSON.parse(raw) as unknown;
136
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
137
+ const map: Record<string, string> = {};
138
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
139
+ if (typeof v === 'string' && v.length > 0) map[k] = v;
140
+ }
141
+ return map;
142
+ }
143
+ } catch {
144
+ // Ignore malformed config; fall back to default-calendar-only behaviour.
145
+ }
146
+ return {};
147
+ }
148
+
149
+ /**
150
+ * Resolve the full CalDAV configuration from config + credential store.
151
+ * The password is resolved from the daemon credential store — either as a
152
+ * goodvibes://secrets/ reference stored in config, or the config-key-derived
153
+ * secret. Throws HandlerError when the surface is not configured. The resolved
154
+ * password is held only in memory and never returned to callers.
155
+ */
156
+ export async function resolveCalDavConfig(
157
+ ctx: CalDavConfigContext,
158
+ ): Promise<CalDavConfig> {
159
+ const credentials: DaemonCredentialStore = ctx.credentials;
160
+ const baseUrl = readConfigString(ctx, CFG_URL);
161
+ const username = readConfigString(ctx, CFG_USER);
162
+ if (!baseUrl || !username) {
163
+ throw new HandlerError(
164
+ 'CalDAV is not configured. Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser.',
165
+ 'CALENDAR_NOT_CONFIGURED',
166
+ 412,
167
+ );
168
+ }
169
+
170
+ const passwordConfig = readConfigString(ctx, CFG_PASSWORD);
171
+ let password: string | null = null;
172
+ if (passwordConfig) {
173
+ // Config may hold a goodvibes://secrets/ ref or a raw secret key.
174
+ password = await credentials.resolveRef(passwordConfig);
175
+ }
176
+ if (!password) {
177
+ // Fall back to the config-key-derived secret.
178
+ password = await credentials.resolveConfigSecret(CFG_PASSWORD);
179
+ }
180
+ if (!password) {
181
+ throw new HandlerError(
182
+ 'CalDAV password is not available in the credential store.',
183
+ 'CALENDAR_CREDENTIALS_MISSING',
184
+ 412,
185
+ );
186
+ }
187
+
188
+ const defaultCalendarId = readConfigString(ctx, CFG_DEFAULT_CALENDAR) ?? 'default';
189
+ const collectionMap = parseCollectionMap(readConfigString(ctx, CFG_CALENDARS));
190
+
191
+ return { baseUrl, username, password, defaultCalendarId, collectionMap };
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // URL helpers (logical id <-> collection path; never expose absolute URLs)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function stripTrailingSlash(value: string): string {
199
+ return value.endsWith('/') ? value.slice(0, -1) : value;
200
+ }
201
+
202
+ function joinUrl(base: string, segment: string): string {
203
+ const cleanBase = stripTrailingSlash(base);
204
+ const cleanSegment = segment.startsWith('/') ? segment : `/${segment}`;
205
+ return `${cleanBase}${cleanSegment}`;
206
+ }
207
+
208
+ /** Extract the scheme+host origin from an absolute URL (no trailing slash). */
209
+ export function originOf(url: string): string {
210
+ const match = /^(https?:\/\/[^/]+)/i.exec(url.trim());
211
+ return match ? match[1]! : stripTrailingSlash(url);
212
+ }
213
+
214
+ /**
215
+ * Resolve a configured collection path. A host-absolute path (starting with
216
+ * '/') is resolved against the origin of the base URL; any other value is
217
+ * treated as a child segment relative to the base URL.
218
+ */
219
+ function resolveCollectionUrl(baseUrl: string, path: string): string {
220
+ if (path.startsWith('/')) {
221
+ return `${originOf(baseUrl)}${stripTrailingSlash(path)}`;
222
+ }
223
+ return joinUrl(baseUrl, stripTrailingSlash(path));
224
+ }
225
+
226
+ /**
227
+ * Convert an absolute or host-relative href returned by the server into an
228
+ * opaque, host-relative identifier. Strips scheme + host so authenticated URLs
229
+ * never leak to callers.
230
+ */
231
+ export function toRelativeHref(href: string): string {
232
+ const trimmed = href.trim();
233
+ const schemeMatch = /^https?:\/\/[^/]+(\/.*)$/i.exec(trimmed);
234
+ if (schemeMatch) return schemeMatch[1]!;
235
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // REPORT / PROPFIND body builders
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function calendarQueryBody(from?: string, to?: string): string {
243
+ const timeFilter = from || to
244
+ ? `<C:time-range${from ? ` start="${toCalDavStamp(from)}"` : ''}${to ? ` end="${toCalDavStamp(to)}"` : ''}/>`
245
+ : '';
246
+ return [
247
+ '<?xml version="1.0" encoding="utf-8" ?>',
248
+ '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
249
+ ' <D:prop>',
250
+ ' <D:getetag/>',
251
+ ' <C:calendar-data/>',
252
+ ' </D:prop>',
253
+ ' <C:filter>',
254
+ ' <C:comp-filter name="VCALENDAR">',
255
+ ` <C:comp-filter name="VEVENT">${timeFilter}</C:comp-filter>`,
256
+ ' </C:comp-filter>',
257
+ ' </C:filter>',
258
+ '</C:calendar-query>',
259
+ ].join('\n');
260
+ }
261
+
262
+ /**
263
+ * Build a calendar-query REPORT body that matches a single VEVENT by UID using
264
+ * a server-side text-match prop-filter. This lets the server return only the
265
+ * one matching resource instead of the entire collection, so a UID lookup is
266
+ * O(1) on the wire and cannot silently miss an event truncated from a large
267
+ * unfiltered listing.
268
+ */
269
+ function calendarQueryByUidBody(uid: string): string {
270
+ // Escape XML metacharacters in the UID before embedding it in the filter.
271
+ const safeUid = uid
272
+ .replace(/&/g, '&amp;')
273
+ .replace(/</g, '&lt;')
274
+ .replace(/>/g, '&gt;')
275
+ .replace(/"/g, '&quot;');
276
+ return [
277
+ '<?xml version="1.0" encoding="utf-8" ?>',
278
+ '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
279
+ ' <D:prop>',
280
+ ' <D:getetag/>',
281
+ ' <C:calendar-data/>',
282
+ ' </D:prop>',
283
+ ' <C:filter>',
284
+ ' <C:comp-filter name="VCALENDAR">',
285
+ ' <C:comp-filter name="VEVENT">',
286
+ ` <C:prop-filter name="UID"><C:text-match collation="i;octet">${safeUid}</C:text-match></C:prop-filter>`,
287
+ ' </C:comp-filter>',
288
+ ' </C:comp-filter>',
289
+ ' </C:filter>',
290
+ '</C:calendar-query>',
291
+ ].join('\n');
292
+ }
293
+
294
+ function propfindCalendarsBody(): string {
295
+ return [
296
+ '<?xml version="1.0" encoding="utf-8" ?>',
297
+ '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
298
+ ' <D:prop>',
299
+ ' <D:displayname/>',
300
+ ' <D:resourcetype/>',
301
+ ' </D:prop>',
302
+ '</D:propfind>',
303
+ ].join('\n');
304
+ }
305
+
306
+ function toCalDavStamp(iso: string): string {
307
+ const date = new Date(iso);
308
+ if (Number.isNaN(date.getTime())) {
309
+ throw new HandlerError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
310
+ }
311
+ const pad = (n: number): string => (n < 10 ? `0${n}` : String(n));
312
+ return (
313
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`
314
+ + `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
315
+ );
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Multistatus (207) response parsing
320
+ // ---------------------------------------------------------------------------
321
+
322
+ interface MultiStatusEntry {
323
+ href: string;
324
+ calendarData?: string;
325
+ displayName?: string;
326
+ isCalendar: boolean;
327
+ }
328
+
329
+ function decodeXmlEntities(value: string): string {
330
+ return value
331
+ .replace(/&lt;/g, '<')
332
+ .replace(/&gt;/g, '>')
333
+ .replace(/&quot;/g, '"')
334
+ .replace(/&#39;/g, "'")
335
+ .replace(/&apos;/g, "'")
336
+ .replace(/&amp;/g, '&');
337
+ }
338
+
339
+ function tagContent(block: string, localName: string): string | undefined {
340
+ // Match <ns:local ...>content</ns:local> or <local>content</local>.
341
+ const re = new RegExp(
342
+ `<(?:[a-zA-Z0-9]+:)?${localName}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${localName}>`,
343
+ 'i',
344
+ );
345
+ const match = re.exec(block);
346
+ return match ? match[1] : undefined;
347
+ }
348
+
349
+ function hasTag(block: string, localName: string): boolean {
350
+ const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${localName}(?:\\s[^>]*)?\\/?>`, 'i');
351
+ return re.test(block);
352
+ }
353
+
354
+ /** Parse a WebDAV 207 multistatus document into per-resource entries. */
355
+ export function parseMultiStatus(xml: string): MultiStatusEntry[] {
356
+ const entries: MultiStatusEntry[] = [];
357
+ const responseRe = /<(?:[a-zA-Z0-9]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi;
358
+ let match: RegExpExecArray | null;
359
+ while ((match = responseRe.exec(xml)) !== null) {
360
+ const block = match[1]!;
361
+ const hrefRaw = tagContent(block, 'href');
362
+ if (!hrefRaw) continue;
363
+ const href = decodeXmlEntities(hrefRaw.trim());
364
+ const calendarDataRaw = tagContent(block, 'calendar-data');
365
+ const displayNameRaw = tagContent(block, 'displayname');
366
+ const isCalendar = hasTag(block, 'calendar');
367
+ entries.push({
368
+ href,
369
+ calendarData: calendarDataRaw ? decodeXmlEntities(calendarDataRaw) : undefined,
370
+ displayName: displayNameRaw ? decodeXmlEntities(displayNameRaw.trim()) : undefined,
371
+ isCalendar,
372
+ });
373
+ }
374
+ return entries;
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Client implementation
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export interface CreateCalDavClientOptions {
382
+ config: CalDavConfig;
383
+ fetchImpl?: FetchLike;
384
+ }
385
+
386
+ const defaultFetch: FetchLike = (input, init) =>
387
+ fetch(input, init as RequestInit) as unknown as ReturnType<FetchLike>;
388
+
389
+ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavClient {
390
+ const { config } = options;
391
+ const doFetch = options.fetchImpl ?? defaultFetch;
392
+ const authHeader = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
393
+
394
+ const collectionPathFor = (calendarId: string | undefined): string => {
395
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
396
+ const mapped = config.collectionMap[id];
397
+ if (mapped) return mapped;
398
+ if (id === config.defaultCalendarId) return ''; // base URL is the default collection
399
+ // Unknown logical id with no mapping: treat the id as a child collection name.
400
+ return `/${encodeURIComponent(id)}/`;
401
+ };
402
+
403
+ const collectionUrlFor = (calendarId: string | undefined): string => {
404
+ const path = collectionPathFor(calendarId);
405
+ return path.length > 0 ? resolveCollectionUrl(config.baseUrl, path) : stripTrailingSlash(config.baseUrl);
406
+ };
407
+
408
+ const request = async (
409
+ url: string,
410
+ method: string,
411
+ body?: string,
412
+ extraHeaders?: Record<string, string>,
413
+ ): Promise<{ status: number; text: string; headers: { get(n: string): string | null } }> => {
414
+ let response;
415
+ try {
416
+ response = await doFetch(url, {
417
+ method,
418
+ headers: {
419
+ Authorization: authHeader,
420
+ ...(body !== undefined
421
+ ? { 'Content-Type': method === 'PUT' ? 'text/calendar; charset=utf-8' : 'application/xml; charset=utf-8' }
422
+ : {}),
423
+ ...extraHeaders,
424
+ },
425
+ body,
426
+ });
427
+ } catch {
428
+ // Redact like the HTTP-status branch below: the raw fetch error
429
+ // (e.g. "getaddrinfo ENOTFOUND cal.example.com") leaks the CalDAV
430
+ // hostname/URL into caller-visible output (importIcs errors[]).
431
+ // Never include creds/URL/host or raw fetch detail.
432
+ throw new HandlerError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
433
+ }
434
+ const text = await response.text();
435
+ if (!response.ok) {
436
+ // Map auth/permission/not-found to handler errors. Never include creds/URL.
437
+ const status = response.status;
438
+ const code = status === 401 || status === 403
439
+ ? 'CALENDAR_AUTH_FAILED'
440
+ : status === 404
441
+ ? 'CALENDAR_NOT_FOUND'
442
+ : 'CALENDAR_REQUEST_FAILED';
443
+ throw new HandlerError(
444
+ `CalDAV server returned HTTP ${status}.`,
445
+ code,
446
+ status >= 400 && status < 500 ? status : 502,
447
+ );
448
+ }
449
+ return { status: response.status, text, headers: response.headers };
450
+ };
451
+
452
+ const buildEvents = (
453
+ entries: MultiStatusEntry[],
454
+ calendarId: string,
455
+ ): CalDavEvent[] => {
456
+ const events: CalDavEvent[] = [];
457
+ for (const entry of entries) {
458
+ if (!entry.calendarData) continue;
459
+ const parsed = parseICS(entry.calendarData);
460
+ for (const event of parsed) {
461
+ events.push({ ...event, href: toRelativeHref(entry.href), calendarId });
462
+ }
463
+ }
464
+ return events;
465
+ };
466
+
467
+ return {
468
+ async listCalendars() {
469
+ const url = stripTrailingSlash(config.baseUrl);
470
+ const { text } = await request(url, 'PROPFIND', propfindCalendarsBody(), { Depth: '1' });
471
+ const entries = parseMultiStatus(text);
472
+ const calendars: Array<{ calendarId: string; displayName: string }> = [];
473
+ const seen = new Set<string>();
474
+ for (const entry of entries) {
475
+ if (!entry.isCalendar) continue;
476
+ const rel = toRelativeHref(entry.href);
477
+ // Derive a stable logical id from the last path segment.
478
+ const segments = rel.split('/').filter((s) => s.length > 0);
479
+ const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]!) : config.defaultCalendarId;
480
+ if (seen.has(logical)) continue;
481
+ seen.add(logical);
482
+ calendars.push({ calendarId: logical, displayName: entry.displayName ?? logical });
483
+ }
484
+ if (calendars.length === 0) {
485
+ calendars.push({ calendarId: config.defaultCalendarId, displayName: config.defaultCalendarId });
486
+ }
487
+ return calendars;
488
+ },
489
+
490
+ async listEvents(opts) {
491
+ const calendarId = opts.calendarId && opts.calendarId.length > 0 ? opts.calendarId : config.defaultCalendarId;
492
+ const url = collectionUrlFor(calendarId);
493
+ const { text } = await request(url, 'REPORT', calendarQueryBody(opts.from, opts.to), { Depth: '1' });
494
+ const entries = parseMultiStatus(text);
495
+ let events = buildEvents(entries, calendarId);
496
+ events.sort((a, b) => a.start.localeCompare(b.start));
497
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
498
+ if (events.length > limit) events = events.slice(0, limit);
499
+ return events;
500
+ },
501
+
502
+ async getEvent(eventId, calendarId) {
503
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
504
+ const collectionUrl = collectionUrlFor(id);
505
+
506
+ // Strategy 1 — href-like identifiers (a relative path, or a *.ics
507
+ // resource name): resolve to a single resource URL and GET it directly.
508
+ // This is a true single-event fetch, not a collection scan.
509
+ if (isHrefLike(eventId)) {
510
+ const resourceUrl = resolveResourceUrl(collectionUrl, config.baseUrl, eventId);
511
+ let single;
512
+ try {
513
+ single = await request(resourceUrl, 'GET', undefined, { Depth: '0' });
514
+ } catch (error) {
515
+ // A 404 maps to "not found"; any other failure propagates.
516
+ if (error instanceof HandlerError && error.code === 'CALENDAR_NOT_FOUND') {
517
+ return null;
518
+ }
519
+ throw error;
520
+ }
521
+ const parsed = parseICS(single.text);
522
+ if (parsed.length === 0) return null;
523
+ const relHref = toRelativeHref(eventId);
524
+ return { ...parsed[0]!, href: relHref, calendarId: id };
525
+ }
526
+
527
+ // Strategy 2 — bare UID: ask the server for ONLY the matching resource via
528
+ // a UID prop-filter calendar-query. The server does the matching, so this
529
+ // is O(1) on the wire and cannot miss an event hidden past a list cutoff.
530
+ const { text } = await request(
531
+ collectionUrl,
532
+ 'REPORT',
533
+ calendarQueryByUidBody(eventId),
534
+ { Depth: '1' },
535
+ );
536
+ const entries = parseMultiStatus(text);
537
+ const events = buildEvents(entries, id);
538
+ // Some servers ignore unsupported prop-filters and return the collection;
539
+ // confirm the exact UID match client-side so we never return a wrong event.
540
+ const found = events.find((event) => event.uid === eventId);
541
+ return found ?? null;
542
+ },
543
+
544
+ async createEvent(input) {
545
+ const calendarId = input.calendarId && input.calendarId.length > 0 ? input.calendarId : config.defaultCalendarId;
546
+ const uid = `${crypto.randomUUID()}@goodvibes`;
547
+ const createdAt = new Date().toISOString();
548
+ const ics = generateICS({
549
+ uid,
550
+ summary: input.title,
551
+ start: input.start,
552
+ end: input.end,
553
+ description: input.description,
554
+ location: input.location,
555
+ attendees: input.attendees,
556
+ status: 'confirmed',
557
+ dtStamp: createdAt,
558
+ });
559
+ const resourcePath = `${uid}.ics`;
560
+ const collectionUrl = collectionUrlFor(calendarId);
561
+ const resourceUrl = joinUrl(collectionUrl, resourcePath);
562
+ const { headers } = await request(resourceUrl, 'PUT', ics, { 'If-None-Match': '*' });
563
+ // Prefer the server-assigned location; otherwise the relative resource path.
564
+ const location = headers.get('Location');
565
+ const href = location ? toRelativeHref(location) : toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath));
566
+ return { eventId: href, uid, createdAt };
567
+ },
568
+
569
+ async importIcs(icsContent, calendarId) {
570
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
571
+ const parsed = parseICS(icsContent);
572
+ if (parsed.length === 0) {
573
+ throw new HandlerError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
574
+ }
575
+ const collectionUrl = collectionUrlFor(id);
576
+ const eventIds: string[] = [];
577
+ const errors: string[] = [];
578
+ let imported = 0;
579
+ for (const event of parsed) {
580
+ const uid = event.uid && event.uid.length > 0 ? event.uid : `${crypto.randomUUID()}@goodvibes`;
581
+ try {
582
+ const ics = generateICS(parsedToGenerateInput(event, uid));
583
+ const resourcePath = `${uid}.ics`;
584
+ const resourceUrl = joinUrl(collectionUrl, resourcePath);
585
+ await request(resourceUrl, 'PUT', ics);
586
+ eventIds.push(toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath)));
587
+ imported += 1;
588
+ } catch (error) {
589
+ const message = error instanceof HandlerError ? error.message : error instanceof Error ? error.message : String(error);
590
+ errors.push(`${uid}: ${message}`);
591
+ }
592
+ }
593
+ return { imported, eventIds, errors };
594
+ },
595
+
596
+ async exportIcs(opts) {
597
+ const id = opts.calendarId && opts.calendarId.length > 0 ? opts.calendarId : config.defaultCalendarId;
598
+ const url = collectionUrlFor(id);
599
+ const { text } = await request(url, 'REPORT', calendarQueryBody(opts.from, opts.to), { Depth: '1' });
600
+ const entries = parseMultiStatus(text);
601
+ const inputs: GenerateICalInput[] = [];
602
+ for (const entry of entries) {
603
+ if (!entry.calendarData) continue;
604
+ for (const event of parseICS(entry.calendarData)) {
605
+ inputs.push(parsedToGenerateInput(event, event.uid || `${crypto.randomUUID()}@goodvibes`));
606
+ }
607
+ }
608
+ const icsContent = generateCalendar(inputs);
609
+ return { icsContent, eventCount: inputs.length };
610
+ },
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Heuristic: does an event identifier look like a resource href rather than a
616
+ * bare iCalendar UID? Hrefs contain a path separator or end in `.ics`; UIDs
617
+ * are opaque tokens (often `uuid@host`) without a path component.
618
+ */
619
+ function isHrefLike(eventId: string): boolean {
620
+ return eventId.includes('/') || /\.ics$/i.test(eventId);
621
+ }
622
+
623
+ /**
624
+ * Resolve an href-like identifier to an absolute, authenticated resource URL
625
+ * for a direct GET. A host-absolute href (starting with '/') is resolved
626
+ * against the base URL origin; any other value is treated as a resource name
627
+ * within the target collection.
628
+ */
629
+ function resolveResourceUrl(collectionUrl: string, baseUrl: string, eventId: string): string {
630
+ const trimmed = eventId.trim();
631
+ if (trimmed.startsWith('/')) {
632
+ return `${originOf(baseUrl)}${trimmed}`;
633
+ }
634
+ return joinUrl(collectionUrl, trimmed);
635
+ }
636
+
637
+ /** Compute the host-relative collection path from an absolute collection URL. */
638
+ function collectionPathOrRoot(collectionUrl: string, baseUrl: string): string {
639
+ const rel = toRelativeHref(collectionUrl);
640
+ if (rel.length > 0) return rel;
641
+ return toRelativeHref(baseUrl);
642
+ }
643
+
644
+ function parsedToGenerateInput(event: ParsedICalEvent, uid: string): GenerateICalInput {
645
+ return {
646
+ uid,
647
+ summary: event.summary,
648
+ start: event.start,
649
+ end: event.end,
650
+ description: event.description,
651
+ location: event.location,
652
+ // Re-emit attendees by their raw value so import preserves the original
653
+ // addressing; display-name-only redaction applies to handler responses,
654
+ // not to server-side payloads.
655
+ attendees: event.attendees.map((a) => a.rawValue),
656
+ organizer: event.organizerRaw,
657
+ status: event.status,
658
+ recurrence: event.recurrence,
659
+ allDay: event.allDay,
660
+ };
661
+ }