Files
familysync/apps/api/src/broker/write.ts
T

116 lines
4.8 KiB
TypeScript

/**
* 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> {
// WR-03: a missing etag maps to NO If-Match header → an UNCONDITIONAL PUT, which
// defeats D-08 conflict detection for exactly the rows most likely to be stale (an
// event cached before an etag was captured, or one Fastmail omitted the etag for).
// We do not block the write (it would strand the user's edit), but we make the
// unconditional-write path observable so it can be diagnosed instead of silently
// overwriting a concurrent external edit with no 412.
if (etag == null || etag === '') {
console.warn(
`[write] updateCalendarObject dispatching with NO If-Match (unconditional PUT) — conflict detection disabled for url=${calendarObjectUrl}`,
)
}
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> {
// WR-03: see updateCalendarEvent — a missing etag is an unconditional DELETE that
// bypasses D-08 conflict detection. Log so the path is observable rather than silent.
if (etag == null || etag === '') {
console.warn(
`[write] deleteCalendarObject dispatching with NO If-Match (unconditional DELETE) — conflict detection disabled for url=${calendarObjectUrl}`,
)
}
return client.deleteCalendarObject({
calendarObject: {
url: calendarObjectUrl,
data: '', // tsdav deleteCalendarObject requires the DAVCalendarObject shape; data unused
etag: etag ?? '',
},
})
}