diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts index ed2bf9c..7089e8f 100644 --- a/apps/api/src/broker/vevent.ts +++ b/apps/api/src/broker/vevent.ts @@ -80,8 +80,10 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number] const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number] - const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }) - const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true }) + // ICAL.Timezone.localTimezone is passed as the zone arg required by TS types. + // isDate:true suppresses any time/TZID output regardless of zone. (D-13) + const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }, ICAL.Timezone.localTimezone) + const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true }, ICAL.Timezone.localTimezone) vevent.addPropertyWithValue('dtstart', startTime) vevent.addPropertyWithValue('dtend', endTime) } else { diff --git a/apps/api/src/broker/write.ts b/apps/api/src/broker/write.ts new file mode 100644 index 0000000..4ab54da --- /dev/null +++ b/apps/api/src/broker/write.ts @@ -0,0 +1,97 @@ +/** + * CalDAV write wrappers — broker boundary enforcement (D-12). + * + * This file is the ONLY place outside client.ts/sync.ts/poller.ts that issues + * PUT/DELETE to Fastmail via tsdav. Route handlers MUST NOT import tsdav directly + * (D-12 broker boundary). The outbox worker (outboxWorker.ts) calls these functions. + * + * T-03-04 (Spoofing / etag forgery): etag is sourced server-side from calendarEvents.etag + * by the worker — never accepted from the browser. write.ts only forwards what the + * server supplies. + * + * T-03-05 (Elevation of privilege): Calendar ownership is enforced at the route layer + * (Plan 04, V4). write.ts is a low-level primitive with no auth context. + * + * Status code interpretation is intentionally deferred to the caller (outboxWorker.ts): + * - 201 / 204: success + * - 412: conflict (D-08) → worker routes to conflict flow, no retry + * - 400 / 401 / 403: hard fail → worker stops retrying immediately (D-07) + * - 5xx / network error: transient → worker applies exponential backoff (D-07) + * + * Sources: + * - https://tsdav.vercel.app/docs/caldav/createCalendarObject + * - https://tsdav.vercel.app/docs/caldav/updateCalendarObject + * - https://github.com/natelindev/tsdav/blob/main/src/request.ts (If-Match confirmed) + */ + +import type { FastmailClient } from './client.js' +import type { DAVCalendar } from 'tsdav' + +/** + * Creates a new CalDAV object via PUT with If-None-Match: * (create semantics). + * + * @param client - Authenticated tsdav DAVClient + * @param calendar - Target DAVCalendar (includes the calendar URL) + * @param uid - Event UID; used as the object filename (`${uid}.ics`) + * @param icsString - Full VCALENDAR/VEVENT string (from buildVeventString) + * @returns Raw Response — caller interprets status codes + */ +export async function createCalendarEvent( + client: FastmailClient, + calendar: DAVCalendar, + uid: string, + icsString: string, +): Promise { + return client.createCalendarObject({ + calendar, + filename: `${uid}.ics`, + iCalString: icsString, + }) +} + +/** + * Updates an existing CalDAV object via PUT with If-Match: (D-08 conflict check). + * + * @param client - Authenticated tsdav DAVClient + * @param calendarObjectUrl - Full URL of the object (from calendarEvents.objectUrl) + * @param icsString - Updated VCALENDAR/VEVENT string + * @param etag - Cached etag from calendarEvents.etag; null is passed as '' + * (tsdav: etag → If-Match header; '' = no If-Match = unconditional) + * @returns Raw Response — 412 signals conflict (D-08) + */ +export async function updateCalendarEvent( + client: FastmailClient, + calendarObjectUrl: string, + icsString: string, + etag: string | null, +): Promise { + return client.updateCalendarObject({ + calendarObject: { + url: calendarObjectUrl, + data: icsString, + etag: etag ?? '', // tsdav maps etag → If-Match header; '' skips the header (safe default) + }, + }) +} + +/** + * Deletes a CalDAV object via DELETE with If-Match: (D-08 conflict check). + * + * @param client - Authenticated tsdav DAVClient + * @param calendarObjectUrl - Full URL of the object (from calendarEvents.objectUrl) + * @param etag - Cached etag from calendarEvents.etag; null is passed as '' + * @returns Raw Response — 412 signals conflict (D-08) + */ +export async function deleteCalendarEvent( + client: FastmailClient, + calendarObjectUrl: string, + etag: string | null, +): Promise { + return client.deleteCalendarObject({ + calendarObject: { + url: calendarObjectUrl, + data: '', // tsdav deleteCalendarObject requires the DAVCalendarObject shape; data unused + etag: etag ?? '', + }, + }) +}