Skip to main content

Calendar providers (Google, Microsoft, Apple)

Locked product and architecture spec for connecting any of the three major calendar hosts to Compass. Approved 2026-09-03. Supersedes the 2026-07-16 internal decision that deferred iCloud to an outbound ICS feed.

Goal​

A user connects Google Calendar, Outlook (Microsoft 365 or outlook.com), or iCloud Calendar to Compass and everything works the same way: events import, edits flow both ways, recurring series and exceptions round-trip, availability for booking is computed from every healthy connection, conference links are created where the host supports them, and per-connection health is honest.

Status​

Google, Microsoft, and Apple adapters register in buildProviderRegistry when their config is present. Work is tracked across six GitHub milestones, each with a tracking issue:

MilestonePurpose
Providers L: loop + CI accelerationAgent loop generalization and CI critical-path cuts. Runs first.
Providers P0: foundationProvider-plural contracts and wiring with Google unchanged.
Providers M: Microsoft (Outlook)Microsoft Graph adapter set and Connect Microsoft UI.
Providers A: Apple (iCloud)CalDAV adapter set, password credential, Connect iCloud UI.
Providers I: identity decouplingSign in with Microsoft and Apple; login is not calendar hosting.
Providers C: closeoutDrop plaintext OAuth rows, per-provider health snapshot, drop GOOGLE_REVOKED.

Locked decisions​

  • Apple is a full iCloud connection over CalDAV using an Apple app-specific password. There is no push from iCloud, so freshness is polling, targeted at one to two minutes.
  • Login and calendar hosting are separate concepts. Microsoft and Apple join Google as SuperTokens login methods. Connecting a calendar is always its own step. Signing in with Google or Microsoft may auto-connect that calendar; Sign in with Apple grants no calendar access and never does.
  • Microsoft uses the /common endpoint so personal and work or school accounts both work with one app registration. Tenants that require admin consent get an honest error state, not a retry loop.
  • Provider behavior enters through capabilities, never through provider === "x" branches in domain or web code.
  • Google keeps working unchanged through every foundation change.
  • User-facing Microsoft copy uses "Microsoft", never "Outlook". The account, toasts, banners, chooser, and Settings rows say Microsoft (or Microsoft Calendar, parallel to Google Calendar). "Outlook" is not shown in the product UI; command-palette search still accepts "outlook" as a keyword for Manage Accounts.
  • An account is provider + email, never email alone. The same address can be a Google account and a Microsoft account, so the web app keys sidebar sections, collapse state, and Settings rows by accountKey (provider + email) and, in Settings, shows a provider mark (the monochrome logo) beside every account email. The sidebar shows that mark on hover only when the same address is connected on more than one provider.
  • No em-dashes in user-facing copy.

Capability matrix​

CapabilityGoogleMicrosoftApple
Sign in as identityyesyes (Entra /common)yes (Sign in with Apple)
Connect calendarOAuth redirectOAuth redirectEmail plus app-specific password form
Sign-in auto-connects the calendaryesyesno; user picks the host
Push notificationsyes (channels)yes (Graph subscriptions)no; poll every 60 to 90 s
Incremental readssyncTokendeltaLinkRFC 6578 sync-token
Conference link on createGoogle MeetMicrosoft Teams when the mailbox allows itnone
Event colors11 slots plus labelscategories read as hex; no write in v1calendar color only
Attendees and invitationsyesyesyes, server-side scheduling
Provider-managed eventsyes (eventType !== "default")no equivalentno equivalent
Contact suggestionsPeople API/me/peoplenone
Booking destinationyesyesyes, without a video link

Capabilities are the intersection of the granted permission, the calendar's access role, the provider's semantics, and the transport's real behavior. An owning role never manufactures canWatchEvents on a transport without push.

Architecture​

The names in this section match the code that landed in P0. Grep each one; they exist.

The sync service (packages/sync) has provider-neutral ports in packages/sync/src/providers/*.port.ts. Free/busy is computed locally from stored occurrences, so no provider free/busy API is called.

Kinds and capabilities​

  • ProviderKindSchema = google | microsoft | apple (packages/core/src/types/sync/identity.contracts.ts).
  • CalendarProviderSchema = local plus those three (packages/core/src/types/calendar.contracts.ts).
  • ProviderCapabilitySchema: readEvents, writeEvents, readBusy, inviteAttendees, changeNotifications, incrementalChanges, suggestContacts.
  • Display names: PROVIDER_DISPLAY_NAMES / providerDisplayName(kind).
  • Conference kind on a calendar: CalendarConferenceSchema = meet | teams | none, derived by conferenceForProvider from CONFERENCE_BY_PROVIDER (not a provider === branch).

Registry​

ProviderRegistry (packages/sync/src/providers/provider-registry.ts) maps a kind to a ProviderRegistration: adapters, scopes, capabilities, callbackPath, notificationsCallbackPath, capabilitiesFromScopes. buildProviderRegistry(config) is the factory. app.ts, job dispatch, credential custody, and the public routes resolve adapters per connection through registry.get(kind).

Google registers when google.clientId / google.clientSecret are set, Microsoft when microsoft.clientId / microsoft.clientSecret are set, and Apple when its config is present.

ProviderAdapters on a registration:

FieldPortMethods
authProviderAuthAdapterbuildAuthorizationUrl, exchangeAuthorizationCode, refreshAccessToken, revoke
calendarsProviderCalendarAdapterdiscoverCalendars
readerProviderEventReaderlistEventPage
writerProviderEventWritercreateEvent, patchEvent, deleteEvent, fetchEvent, fetchInstanceAt
notificationsProviderNotificationAdapterwatch, stopChannel, parseNotification
contactsContactsPort (optional)searchContacts

parseNotification returns a ProviderNotification, a Microsoft validation handshake {kind: "validation", body}, or null.

Provider code lives under packages/sync/src/providers/<kind>/. Each adapter takes an injectable narrow API so tests script results without network access. The shared contract suite under packages/sync/src/providers/__contract__/ runs every adapter against a recorded fixture corpus.

Public sync paths​

Constants: OAUTH_CALLBACK_PARAM_PATH, NOTIFICATIONS_PARAM_PATH, plus Google aliases GOOGLE_CALLBACK_PATH and GOOGLE_NOTIFICATIONS_PATH.

PathRole
GET /sync/:providerOAuth callback
GET /sync/googleGoogle alias of the callback
POST /sync/notifications/:providerPush ingress
POST /sync/notifications/googleGoogle alias of push ingress

Compass API connection paths​

PathRole
POST /api/auth/connections/beginStart OAuth; body {provider?, connectionId?, features?}
POST /api/auth/connections/refreshUser-triggered catch-up
DELETE /api/auth/connections/:connectionIdDisconnect

Begin returns {kind: "redirect", authorizationUrl}. The connected response kind {kind: "connected", connectionId} is on ConnectionBeginResponseSchema for Apple WP-03; that credential-form route is not mounted yet.

Sync-internal counterparts: GET /internal/connections, POST /internal/connections/begin, POST /internal/connections/refresh, POST /internal/connections/foreground-refresh, POST /internal/connections/adopt-google-authorization, DELETE /internal/connections/:id, GET /internal/calendars, GET /internal/events/full, POST /internal/availability/busy, GET /internal/contacts/suggestions, POST /internal/commands, DELETE /internal/principal, GET /internal/changes.

Config keys​

KeyRole
google.clientId, google.clientSecretGoogle OAuth (sign-in and calendar)
microsoft.clientId, microsoft.clientSecretMicrosoft OAuth (all-or-none)
apple.signIn.servicesId, apple.signIn.teamId, apple.signIn.keyId, apple.signIn.privateKeySign in with Apple (all-four-or-none; milestone I)
sync.credentialEncryptionKey32-byte base64 AES-256-GCM key for credentials at rest
sync.callbackBaseUrlPublic base for OAuth redirects and webhooks
sync.postConnectRedirectUrlBrowser redirect after connect; defaults to callback base
sync.executionpassive or active
sync.serviceUrl, sync.internalAuthToken, sync.mongoUriBackend-to-sync and Sync storage

Apple polling env overrides (see Apple polling cadence): RECONCILE_STALE_AFTER_MS_APPLE, RECONCILE_SWEEP_INTERVAL_MS_APPLE, RECONCILE_SWEEP_LIMIT_APPLE.

Credentials​

Discriminated on credentialKind: "oauthRefresh" | "password". Documents without credentialKind parse as oauthRefresh.

Password credentials are always sealed at rest. OAuth refresh tokens are always sealed by CredentialCustody.store with sync.credentialEncryptionKey. Plaintext rows are rejected at read time; the sync app refuses to start while any remain. Use bun run cli encrypt-credentials --apply to backfill legacy rows. Whenever any stored OAuth credential exists, the encryption key is required at startup.

Poll-only providers​

Subscription maintenance settles unsupported when the registry capabilities omit changeNotifications. buildReconcileSweepRows adds a reconcile-<kind> row per poll-only kind. Apple defaults: 60 s stale window, 30 s sweep interval (±20% jitter), batch limit 500.

User metadata​

UserMetadata.connections[] is the provider-neutral list of every connected account. The browser derives aggregate Google UI state from the Google rows in that list.

Connect flows​

Google and Microsoft (redirect). POST /api/auth/connections/begin {provider} returns {kind: "redirect", authorizationUrl}. The browser navigates there, the provider redirects to the sync service's GET /sync/:provider callback, sync links the connection and enqueues calendar list discovery, then redirects back with ?provider=<kind>&status=<status>.

Apple (credential form, Apple WP-03). The user creates an app-specific password at appleid.apple.com and submits email plus password in Compass. The secret travels in the existing encrypted transit envelope. Sync validates it by running CalDAV discovery, stores the encrypted credential, enqueues discovery and returns {kind: "connected", connectionId}. The password is never logged. The UI states plainly that an app-specific password grants access to the whole iCloud account and that Compass stores it encrypted.

Apple invitations. iCloud performs server-side scheduling by default when ATTENDEE lines are present (SCHEDULE-AGENT=SERVER), so adding guests makes iCloud send mail. Compass maps invitation: "none" to SCHEDULE-AGENT=CLIENT on attendee parameters so edits do not trigger mail. invitation: "all" and "externalOnly" leave server scheduling enabled.

Booking​

Booking enables when any healthy connection offers a writable destination calendar (canWriteEvents). Empty healthy set is CALENDAR_NOT_CONNECTED. A destination that cannot be written is DESTINATION_NOT_WRITABLE.

The confirmation copy names the conference kind the destination supports (meet, teams, none). An Apple destination creates the event without a video link and says so.

Identity​

user.identities[] records {provider, subjectId, email} per login method (milestone I). Identity is the provider subject, never email alone.

Compass resolves the same verified email across Google and Microsoft to one Compass user in userService.getCanonicalCompassUserId. SuperTokens automatic account linking is deliberately not enabled because the SuperTokens Cloud plan lacks the feature and the core rejects createPrimaryUser with 402. Google and Microsoft emails from the id_token count as verified when the token says so. Email/password accounts link only after email verification. Apple private-relay addresses (@privaterelay.appleid.com) never link automatically; Sign in with Apple identifies by sub.

Linking merges identities[] and keeps every calendar connection of both users.

After signup with a method that grants no calendar, onboarding asks which service hosts the calendar: "If you view your calendar in Apple Calendar, it may still be hosted by Google or Microsoft."

Named warts​

Each alias names the release that removes it.

  • Microsoft category colors are read but never written back.
  • Apple freshness depends on polling and is bounded by iCloud rate limits.
  • Provider-managed events. Google is the only provider that sets providerManaged today: its reader marks any event whose eventType is not "default" (for example fromGmail events created from forwarded confirmations when "Events from Gmail" is on). Microsoft and Apple readers leave providerManaged unset; their writers ignore the hint. On patch, the writer receives providerManaged: true on the input and sends only the body fields that provider accepts (Google: colorId and attendees).

Microsoft registration and staging proof​

The hosted Compass app is registered in the SIMPLE SOFTWARE LLC tenant (keepsoftwaresimple.onmicrosoft.com) as a verified publisher (Microsoft AI Cloud Partner Program ID 7157084, publisher domain keepsoftwaresimple.com, verified 2026-09-11). Consent screens show the verified badge; no "unverified app" warning. Audience is any Entra tenant plus personal Microsoft accounts, matching the /common endpoints the adapter uses. Delegated Graph permissions: User.Read, offline_access, Calendars.ReadWrite, and People.Read (the last only for the contacts feature).

Two redirect URI shapes are registered per deployment, both on the frontend origin (the frontend proxies /sync to the sync service):

FlowURI shape
Connect a calendar (sync service, callbackBaseUrl)<origin>/sync/microsoft
Sign in with Microsoft (web, window.location.origin)<origin>/auth/microsoft/callback

Origins: https://compasscalendar.com, https://staging.compasscalendar.com, https://selfhosted.compasscalendar.com, plus http://localhost:3010 (sync) and http://localhost:9080 (web) for development. The client id is public and baked into the web bundle at build time; the client secret lives only in compass.yaml and the GitHub Environments.

Founder proof on staging, 2026-09-11 (#3208): sign in with Microsoft with the verified badge, calendars connected, an event created in Compass appeared in Outlook, an edit made in Outlook synced back to Compass after a reload, and a booking completed through /meet. The live smoke (live-provider-smoke.yml) now exercises the Microsoft adapters nightly; see docs/CI-CD/live-provider-smoke.md.

Findings from the first live runs, tracked separately: #3659 (booking availability did not block a slot occupied on a Microsoft calendar), #3662 (prompt=select_account consent is rejected by Microsoft, so connecting a second Microsoft account fails), and #3664 (the smoke job is green when every provider is skipped). Exchange aligns recurring occurrences to whole minutes; fetchInstanceAt matches at minute precision for that reason.

Work-tenant proof on staging, 2026-09-15 (#3676): a licensed Exchange Online test mailbox connected alongside the existing personal Microsoft account. Compass discovered its primary and compass-smoke calendars. An event created in Compass appeared in Outlook, and an Outlook title edit appeared in Compass after a reload. Both connections reported healthy. A booking with the work compass-smoke calendar as its destination appeared in Outlook. This mailbox does not support Teams links; Settings explained that limitation and the booking completed without a Teams URL.

This proof used the deployed staging OAuth callback and calendar adapters, instead of the local microsoft:mint-token --print-only command. No work-account token was written to the nightly smoke environment. The personal-account live smoke also passed on 2026-09-15, with SMOKE_EXPECTED_PROVIDERS=microsoft now required in that environment.

Microsoft invitation limitation. Graph can send attendee mail on event creation and attendee changes even when responseRequested is false. The writer sets that field for invitation intent none, but it cannot guarantee that Outlook will suppress notifications.

Microsoft Graph event reads​

WP-05 spike (Graph documentation, confirmed against the normalizer fixture corpus):

Endpointtype values returnedCompass use
GET /me/calendars/{id}/events/deltasingleInstance, seriesMaster, exception (no occurrence)Primary incremental reader
GET /me/calendars/{id}/calendarView/deltasingleInstance, occurrence, exception (expanded instances)Windowed bootstrap pass only

Compass reads masters and exceptions, never occurrences. The reader uses events/delta for full and incremental passes (startDateTime bounded to the sync horizon: 12 months past through 18 months future). When the import worker supplies a bounded working window with both ends, the reader uses calendarView/delta because events/delta does not accept endDateTime. Occurrence rows from calendarView/delta are skipped and counted in skipped.

Initial and incremental requests send Prefer: odata.maxpagesize=200 and outlook.timezone="UTC". @odata.nextLink becomes nextPageToken; @odata.deltaLink becomes nextSyncToken (stored as syncCursor). Removed items arrive as {id, "@removed": {reason}} and map to cancellations. A 410 or syncStateNotFound response maps to cursorExpired.

If a future Graph change returns occurrence-only rows from events/delta, fall back to calendarView/delta over the horizon plus a GET /me/events/{seriesMasterId} hop for masters, and update this section.

Apple polling cadence​

Apple has no push channel (changeNotifications is absent from the registration). Freshness comes from the dedicated reconcile-apple sweep row in the sync service.

SettingDefaultEnv override
Stale threshold60 sRECONCILE_STALE_AFTER_MS_APPLE
Sweep interval30 s (±20% jitter)RECONCILE_SWEEP_INTERVAL_MS_APPLE
Batch limit500 calendars per sweepRECONCILE_SWEEP_LIMIT_APPLE

When a connection has N calendars, the effective per-calendar cadence is:

cadence = interval × ceil(N / limit)

Example: 1,000 calendars at limit 500 and interval 30 s needs two cycles, so each calendar is eligible roughly every 60 s (plus the 60 s stale window).

Throttle measurement​

Founder soak uses bun run cli apple-poll-throttle with SMOKE_APPLE_EMAIL and SMOKE_APPLE_APP_PASSWORD. The script polls one calendar with RFC 6578 sync-collection every --interval-seconds for --duration-seconds (default 30 minutes) and records HTTP status codes.

MeasurementValue
Shortest interval without 429/503 over 30 minpending founder soak (apple-poll-throttle)
Provisional floor (pre-soak)20 s
Chosen default interval (1.5× provisional floor)30 s
Chosen stale threshold60 s

Re-run the probe after iCloud behavior changes and replace the provisional floor with the measured minimum.

Deferred​

  • Outbound ICS feed for Apple users who refuse an app-specific password.
  • Writing Microsoft categories or Apple event colors.
  • Publishing one Compass event to several providers.
  • Native EventKit access from an iOS or macOS client.