Native calendars (Launch)

TW-185 stores calendars in Launch Postgres — not Stalwart CalDAV. User calendars (ownerKind=user) and org calendars (ownerKind=org) live in an organization; there are no project-scoped calendars in v1.

Authorization

Calendars are a spine resource type (calendar) with capabilities:

Capability Meaning
calendar.freebusy Busy/free blocks only (no titles in free/busy views)
calendar.view Read events
calendar.edit Create, update, delete events
calendar.manage Calendar settings, sharing, delete calendar

Share presets map to capability sets: owner (all four), editor (edit+view+freebusy), viewer (view+freebusy), freebusy (freebusy only).

Org membership adds a baseline on top of explicit grants:

  • owner/admin — manage all org calendars
  • developer/viewer — calendar.freebusy on all org calendars; calendar.view only when visibility is org (public_freebusy is freebusy-level only, not full event read)
  • email — no calendar access from role alone

Sharing uses the same grant engine as grant_access (share_calendar / unshare_calendar / list_calendar_shares). Subjects must already exist (invite via invite_member first).

REST (authenticated)

Portal session or agent token with appropriate masks:

  • GET/POST /api/calendars, events, shares (phase 1)
  • POST /api/calendars/freebusy — multi-calendar busy query (RRULE, transparency, holds, availability rules)
  • POST /api/calendars/:id/booking/holds — TTL hold (409 on conflict; Idempotency-Key)
  • POST .../booking/holds/:holdId/confirm — confirm hold → event (booking_ref, Idempotency-Key)
  • DELETE .../booking/holds/:holdId — release hold
  • GET/POST/DELETE /api/calendar-app-passwords — CalDAV HTTP Basic credentials (password shown once)
  • GET/POST/DELETE /api/calendars/:id/ics-feeds — secret-token subscribe URLs (full or freebusy_only)
  • POST /api/calendars/:id/events/:eventId/invites — iMIP REQUEST / CANCEL via outbound mail

CalDAV (app password)

  1. Create an app password (POST /api/calendar-app-passwords or MCP create_calendar_app_password).
  2. Point the client at the control-plane origin with discovery:
    • /.well-known/caldav → /dav/
    • Principal collection: /dav/principals/{principalId}/
    • Calendar collection: /dav/calendars/{principalId}/{calendarId}/
  3. Authenticate with HTTP Basic: username (your Tandem login email, assigned automatically) + app password (cal_…). You may create multiple app passwords; each uses the same username.

Supported: PROPFIND, REPORT (calendar-query, sync-collection, free-busy-query), GET/PUT/DELETE on {uid}.ics, etags / If-Match, iCalendar VEVENT round-trip (RRULE, EXDATE, overrides).

Routing: /.well-known/caldav, /dav/* and /ics/* must reach the control-plane API unfiltered. On production, the portal.launchtandem.com and api.launchtandem.com Caddy blocks already proxy every path to the API (verified in OPS-271), so keep those blocks free of path filters. /.well-known/caldav answers GET, HEAD, PROPFIND and OPTIONS with a 301 to /dav/ (Apple clients PROPFIND it during discovery). ICS feed tokens are redacted from API request logs.

Apple Calendar (macOS / iOS)

  • Add account → CalDAV → Advanced → use server URL https://<your-portal-host>/dav/, username + app password.
  • If discovery fails, set the server path to /dav/principals/{principalId}/ from the API.

Thunderbird

  • Calendar → New Calendar → On the Network → CalDAV → location https://<host>/dav/, credentials as above.

DAVx5 (Android)

  • Add account → URL https://<host>/dav/, username + app password; enable sync.

ICS subscribe (read-only)

Mint a feed with POST /api/calendars/:id/ics-feeds (role: full or freebusy_only). Subscribe in Google Calendar or Outlook via “From URL” using the returned https://<host>/ics/{token}/calendar.ics URL. Rotate by revoking and creating a new feed.

  • full: exports event titles and details for events in the feed window (including visibility: private events). Anyone with the secret URL can read them — treat the token like a password.
  • freebusy_only: busy blocks only, titled Reserved (no event titles).

iMIP

Outbound REQUEST/CANCEL uses the org’s verified fromAddress on send_calendar_event_invites / POST .../invites. Each outbound REQUEST embeds a per-attendee X-TANDEM-REPLY-TOKEN in the calendar part; inbound REPLY must include that token (in addition to matching From and an existing attendee line).

Inbound REPLY is processed when mail hits a mailbox on ingest (before optional inbound webhooks): org-scoped lookup, attendee + token verification, then partstat update only (no new attendees).

Known v1 gap (LNCH-300 follow-up): we do not yet require inbound mail authentication aligned with the From domain (DKIM/SPF/DMARC / Authentication-Results). The platform instead relies on org scoping, existing-attendee-only updates, and the per-invite X-TANDEM-REPLY-TOKEN HMAC. That blocks arbitrary senders but not a recipient who forwards an invite (they see UIDs, attendee list, and tokens). A future hardening pass should verify DKIM/DMARC (or signed reply addresses) before applying RSVP changes. Treat outbound invites as sensitive.

MCP

Phase 1 CRUD + sharing tools, plus phase 2: query_calendar_freebusy, booking hold tools, app password tools, ICS feed tools, send_calendar_event_invites.

OAuth calendar scopes

Phase 3 — not in phase 2. Do not expect calendar.* OAuth scopes yet.

Sync-oriented fields

Events carry stable uid, etag, sequence, and per-calendar calendar_changes.seq for CalDAV sync-tokens and ICS feeds.