Milestone v1.0: FamilySync MVP #1
@@ -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 {
|
||||
|
||||
@@ -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<Response> {
|
||||
return client.createCalendarObject({
|
||||
calendar,
|
||||
filename: `${uid}.ics`,
|
||||
iCalString: icsString,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing CalDAV object via PUT with If-Match: <etag> (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<Response> {
|
||||
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: <etag> (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<Response> {
|
||||
return client.deleteCalendarObject({
|
||||
calendarObject: {
|
||||
url: calendarObjectUrl,
|
||||
data: '', // tsdav deleteCalendarObject requires the DAVCalendarObject shape; data unused
|
||||
etag: etag ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user