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:
| Milestone | Purpose |
|---|---|
| Providers L: loop + CI acceleration | Agent loop generalization and CI critical-path cuts. Runs first. |
| Providers P0: foundation | Provider-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 decoupling | Sign in with Microsoft and Apple; login is not calendar hosting. |
| Providers C: closeout | Drop 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
/commonendpoint 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
| Capability | Microsoft | Apple | |
|---|---|---|---|
| Sign in as identity | yes | yes (Entra /common) | yes (Sign in with Apple) |
| Connect calendar | OAuth redirect | OAuth redirect | Email plus app-specific password form |
| Sign-in auto-connects the calendar | yes | yes | no; user picks the host |
| Push notifications | yes (channels) | yes (Graph subscriptions) | no; poll every 60 to 90 s |
| Incremental reads | syncToken | deltaLink | RFC 6578 sync-token |
| Conference link on create | Google Meet | Microsoft Teams when the mailbox allows it | none |
| Event colors | 11 slots plus labels | categories read as hex; no write in v1 | calendar color only |
| Attendees and invitations | yes | yes | yes, server-side scheduling |
| Provider-managed events | yes (eventType !== "default") | no equivalent | no equivalent |
| Contact suggestions | People API | /me/people | none |
| Booking destination | yes | yes | yes, 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=localplus 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 byconferenceForProviderfromCONFERENCE_BY_PROVIDER(not aprovider ===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:
| Field | Port | Methods |
|---|---|---|
auth | ProviderAuthAdapter | buildAuthorizationUrl, exchangeAuthorizationCode, refreshAccessToken, revoke |
calendars | ProviderCalendarAdapter | discoverCalendars |
reader | ProviderEventReader | listEventPage |
writer | ProviderEventWriter | createEvent, patchEvent, deleteEvent, fetchEvent, fetchInstanceAt |
notifications | ProviderNotificationAdapter | watch, stopChannel, parseNotification |
contacts | ContactsPort (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.
| Path | Role |
|---|---|
GET /sync/:provider | OAuth callback |
GET /sync/google | Google alias of the callback |
POST /sync/notifications/:provider | Push ingress |
POST /sync/notifications/google | Google alias of push ingress |
Compass API connection paths
| Path | Role |
|---|---|
POST /api/auth/connections/begin | Start OAuth; body {provider?, connectionId?, features?} |
POST /api/auth/connections/refresh | User-triggered catch-up |
DELETE /api/auth/connections/:connectionId | Disconnect |
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
| Key | Role |
|---|---|
google.clientId, google.clientSecret | Google OAuth (sign-in and calendar) |
microsoft.clientId, microsoft.clientSecret | Microsoft OAuth (all-or-none) |
apple.signIn.servicesId, apple.signIn.teamId, apple.signIn.keyId, apple.signIn.privateKey | Sign in with Apple (all-four-or-none; milestone I) |
sync.credentialEncryptionKey | 32-byte base64 AES-256-GCM key for credentials at rest |
sync.callbackBaseUrl | Public base for OAuth redirects and webhooks |
sync.postConnectRedirectUrl | Browser redirect after connect; defaults to callback base |
sync.execution | passive or active |
sync.serviceUrl, sync.internalAuthToken, sync.mongoUri | Backend-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
providerManagedtoday: its reader marks any event whoseeventTypeis not"default"(for examplefromGmailevents created from forwarded confirmations when "Events from Gmail" is on). Microsoft and Apple readers leaveproviderManagedunset; their writers ignore the hint. On patch, the writer receivesproviderManaged: trueon the input and sends only the body fields that provider accepts (Google:colorIdand 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):
| Flow | URI 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):
| Endpoint | type values returned | Compass use |
|---|---|---|
GET /me/calendars/{id}/events/delta | singleInstance, seriesMaster, exception (no occurrence) | Primary incremental reader |
GET /me/calendars/{id}/calendarView/delta | singleInstance, 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.
| Setting | Default | Env override |
|---|---|---|
| Stale threshold | 60 s | RECONCILE_STALE_AFTER_MS_APPLE |
| Sweep interval | 30 s (±20% jitter) | RECONCILE_SWEEP_INTERVAL_MS_APPLE |
| Batch limit | 500 calendars per sweep | RECONCILE_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.
| Measurement | Value |
|---|---|
| Shortest interval without 429/503 over 30 min | pending founder soak (apple-poll-throttle) |
| Provisional floor (pre-soak) | 20 s |
| Chosen default interval (1.5× provisional floor) | 30 s |
| Chosen stale threshold | 60 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.