From a02a6cfba687c134ef2841adca491042fd4ab809 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 23:54:52 +0000 Subject: [PATCH] feat(sync): implement microsoft calendar discovery adapter Co-authored-by: Tyler Dane --- .agents/handoffs/3241.md | 38 +++ .../__contract__/microsoft.contract.test.ts | 64 ++++ .../microsoft-calendar-capabilities.ts | 15 + .../microsoft/microsoft-calendar-colors.ts | 27 ++ .../microsoft-calendar.adapter.test.ts | 309 ++++++++++++++++++ .../microsoft/microsoft-calendar.adapter.ts | 173 ++++++++++ .../providers/microsoft/microsoft-error.ts | 25 ++ .../microsoft/microsoft-http.constants.ts | 8 + 8 files changed, 659 insertions(+) create mode 100644 .agents/handoffs/3241.md create mode 100644 packages/sync/src/providers/microsoft/microsoft-calendar-capabilities.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-calendar-colors.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-calendar.adapter.test.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-error.ts create mode 100644 packages/sync/src/providers/microsoft/microsoft-http.constants.ts diff --git a/.agents/handoffs/3241.md b/.agents/handoffs/3241.md new file mode 100644 index 000000000..c40278e3d --- /dev/null +++ b/.agents/handoffs/3241.md @@ -0,0 +1,38 @@ +--- +schema_version: 1 +task_id: "3241" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts + - path: packages/sync/src/providers/microsoft/microsoft-calendar.adapter.test.ts + - path: packages/sync/src/providers/microsoft/microsoft-calendar-colors.ts + - path: packages/sync/src/providers/microsoft/microsoft-calendar-capabilities.ts + - path: packages/sync/src/providers/microsoft/microsoft-error.ts + - path: packages/sync/src/providers/microsoft/microsoft-http.constants.ts + - path: packages/sync/src/providers/__contract__/microsoft.contract.test.ts +evidence: + - command: bun test:sync + result: pass + - command: bun run type-check + result: pass + - command: bun lint + result: pass + - command: bun knip + result: pass + - command: bun run verify --strict + result: "VERDICT: PASS" +assumptions: + - "Microsoft is not registered in ProviderRegistry until M-09 (#3249)." + - "Named color enum hex fallbacks use Outlook preset values when Graph omits hexColor." +open_risks: [] +next_deadline: 2026-09-05T02:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +M WP-02: Microsoft calendar discovery adapter with injectable Graph list API, paging, color fallback, access-role mapping, and error classification. diff --git a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts index 591fc61cd..d7b216a24 100644 --- a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts +++ b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts @@ -1,5 +1,6 @@ import { generateKeyPair, type KeyLike, SignJWT } from "jose"; import { type AuthContractCase } from "@sync/providers/__contract__/auth.contract"; +import { type DiscoveryContractCase } from "@sync/providers/__contract__/discovery.contract"; import exchangeFixture from "@sync/providers/__contract__/fixtures/microsoft/exchange-success.json"; import refreshRevokedFixture from "@sync/providers/__contract__/fixtures/microsoft/refresh-invalid-grant.json"; import refreshSuccessFixture from "@sync/providers/__contract__/fixtures/microsoft/refresh-success.json"; @@ -9,6 +10,11 @@ import { type MicrosoftTokenEndpoint, type MicrosoftTokenResponse, } from "@sync/providers/microsoft/microsoft-auth.adapter"; +import { + MicrosoftCalendarAdapter, + type MicrosoftCalendarListApi, + type MicrosoftCalendarListPage, +} from "@sync/providers/microsoft/microsoft-calendar.adapter"; import { ProviderAuthError } from "@sync/providers/provider-auth.port"; const CLIENT_ID = "microsoft-client-id"; @@ -172,3 +178,61 @@ describeAuthCases( }, ], ); + +class ContractCalendarListApi implements MicrosoftCalendarListApi { + async listPage(): Promise { + return { + items: [ + { + id: "primary-cal", + name: "Calendar", + color: "lightBlue", + hexColor: "#0078D4", + canEdit: true, + isDefaultCalendar: true, + }, + { + id: "shared-cal", + name: "Shared", + color: "lightGreen", + hexColor: "#107C10", + canEdit: false, + isDefaultCalendar: false, + }, + ], + nextLink: null, + }; + } +} + +const MICROSOFT_DISCOVERY_CASES: DiscoveryContractCase[] = [ + { + name: "detects a primary calendar, colors, access roles, and a cursor", + username: "user@contoso.com", + password: "secret", + run: async (adapter) => { + const result = await adapter.discoverCalendars({ + accessToken: "contract-access-token", + }); + const primary = result.calendars.filter((calendar) => calendar.primary); + expect(primary).toHaveLength(1); + expect(result.calendars.some((calendar) => calendar.color !== null)).toBe( + true, + ); + expect(result.calendars[0]?.accessRole).toBe("owner"); + expect(result.calendars[1]?.accessRole).toBe("viewer"); + expect(result.cursor).toBeNull(); + }, + }, +]; + +describe("microsoft discovery contract", () => { + for (const testCase of MICROSOFT_DISCOVERY_CASES) { + it(testCase.name, async () => { + const adapter = new MicrosoftCalendarAdapter( + () => new ContractCalendarListApi(), + ); + await testCase.run(adapter); + }); + } +}); diff --git a/packages/sync/src/providers/microsoft/microsoft-calendar-capabilities.ts b/packages/sync/src/providers/microsoft/microsoft-calendar-capabilities.ts new file mode 100644 index 000000000..ab430e035 --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-calendar-capabilities.ts @@ -0,0 +1,15 @@ +import { type SyncCalendarCapabilities } from "@core/types/sync/connection.contracts"; + +// Per-calendar operational capabilities for a discovered Microsoft calendar. +// Push subscriptions are a connection-level fact (changeNotifications); every +// listed calendar can be watched when the connection grants calendar access. +export function microsoftDiscoveredCalendarCapabilities( + canEdit: boolean, +): SyncCalendarCapabilities { + return { + canReadEvents: true, + canWriteEvents: canEdit, + canReadBusy: true, + canInviteAttendees: canEdit, + }; +} diff --git a/packages/sync/src/providers/microsoft/microsoft-calendar-colors.ts b/packages/sync/src/providers/microsoft/microsoft-calendar-colors.ts new file mode 100644 index 000000000..2bc7ab60c --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-calendar-colors.ts @@ -0,0 +1,27 @@ +// Outlook calendar theme presets when Graph returns the named color enum but +// no hexColor (common for auto and never-customized calendars). +export const MICROSOFT_CALENDAR_COLOR_HEX: Readonly< + Record +> = { + auto: null, + lightBlue: "#0078D4", + lightGreen: "#107C10", + lightOrange: "#D83B01", + lightGray: "#666666", + lightYellow: "#FFB900", + lightTeal: "#008272", + lightPink: "#E3008C", + lightBrown: "#986F0B", + lightRed: "#D13438", + maxColor: null, +}; + +export function resolveMicrosoftCalendarColor( + hexColor: string | null | undefined, + color: string | null | undefined, +): string | null { + const trimmedHex = hexColor?.trim(); + if (trimmedHex) return trimmedHex; + if (!color) return null; + return MICROSOFT_CALENDAR_COLOR_HEX[color] ?? null; +} diff --git a/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.test.ts b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.test.ts new file mode 100644 index 000000000..155a2e813 --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.test.ts @@ -0,0 +1,309 @@ +import { + MicrosoftCalendarAdapter, + type MicrosoftCalendarListApi, + type MicrosoftCalendarListPage, + type MicrosoftGraphCalendar, +} from "@sync/providers/microsoft/microsoft-calendar.adapter"; +import { MICROSOFT_CALENDAR_COLOR_HEX } from "@sync/providers/microsoft/microsoft-calendar-colors"; +import { type ProviderCalendarError } from "@sync/providers/provider-calendar.port"; + +class FakeCalendarListApi implements MicrosoftCalendarListApi { + calls: Array<{ nextLink?: string }> = []; + #pages: MicrosoftCalendarListPage[]; + #error?: unknown; + + constructor(pages: MicrosoftCalendarListPage[], error?: unknown) { + this.#pages = pages; + this.#error = error; + } + + async listPage(params: { + nextLink?: string; + }): Promise { + this.calls.push(params); + if (this.#error) throw this.#error; + const page = this.#pages.shift(); + if (!page) throw new Error("FakeCalendarListApi: no page scripted"); + return page; + } +} + +const calendar = ( + overrides: Partial, +): MicrosoftGraphCalendar => ({ + id: "cal-id", + name: "Calendar", + color: "lightBlue", + hexColor: "#0078D4", + canEdit: true, + isDefaultCalendar: false, + ...overrides, +}); + +const page = ( + overrides: Partial, +): MicrosoftCalendarListPage => ({ + items: [], + nextLink: null, + ...overrides, +}); + +function adapterWith(api: MicrosoftCalendarListApi) { + const tokensSeen: string[] = []; + const adapter = new MicrosoftCalendarAdapter((accessToken) => { + tokensSeen.push(accessToken); + return api; + }); + return { adapter, tokensSeen }; +} + +describe("MicrosoftCalendarAdapter", () => { + it("maps a single page and returns a null cursor", async () => { + const api = new FakeCalendarListApi([ + page({ + items: [ + calendar({ + id: "default-cal", + name: "Calendar", + isDefaultCalendar: true, + hexColor: "#112233", + }), + ], + }), + ]); + const { adapter, tokensSeen } = adapterWith(api); + + const result = await adapter.discoverCalendars({ accessToken: "at-1" }); + + expect(tokensSeen).toEqual(["at-1"]); + expect(result.cursor).toBeNull(); + expect(result.calendars).toEqual([ + { + providerCalendarId: "default-cal", + displayName: "Calendar", + color: "#112233", + eventLabels: [], + primary: true, + active: true, + accessRole: "owner", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + createsGoogleMeet: false, + }, + ]); + }); + + it("follows @odata.nextLink pagination and accumulates every page", async () => { + const api = new FakeCalendarListApi([ + page({ + items: [calendar({ id: "a", name: "A" })], + nextLink: "https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=2", + }), + page({ + items: [calendar({ id: "b", name: "B" })], + }), + ]); + const { adapter } = adapterWith(api); + + const result = await adapter.discoverCalendars({ accessToken: "at" }); + + expect(result.calendars.map((entry) => entry.providerCalendarId)).toEqual([ + "a", + "b", + ]); + expect(api.calls).toEqual([ + { nextLink: undefined }, + { + nextLink: "https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=2", + }, + ]); + }); + + it("ignores an incremental cursor input and still returns null", async () => { + const api = new FakeCalendarListApi([ + page({ items: [calendar({ id: "a" })] }), + ]); + const { adapter } = adapterWith(api); + + const result = await adapter.discoverCalendars({ + accessToken: "at", + cursor: "stale-sync-token", + }); + + expect(result.cursor).toBeNull(); + expect(api.calls).toEqual([{ nextLink: undefined }]); + }); + + it("prefers hexColor and falls back to the named color enum table", async () => { + const api = new FakeCalendarListApi([ + page({ + items: [ + calendar({ id: "hex", hexColor: "#AABBCC", color: "lightGreen" }), + calendar({ + id: "enum", + hexColor: "", + color: "lightGreen", + }), + calendar({ id: "auto", hexColor: "", color: "auto" }), + ], + }), + ]); + const { adapter } = adapterWith(api); + + const { calendars } = await adapter.discoverCalendars({ + accessToken: "at", + }); + const byId = Object.fromEntries( + calendars.map((entry) => [entry.providerCalendarId, entry.color]), + ); + + expect(byId).toEqual({ + hex: "#AABBCC", + enum: MICROSOFT_CALENDAR_COLOR_HEX.lightGreen, + auto: null, + }); + }); + + it.each([ + ["owner", { isDefaultCalendar: true, canEdit: false }], + ["editor", { isDefaultCalendar: false, canEdit: true }], + ["viewer", { isDefaultCalendar: false, canEdit: false }], + ] as const)("maps access role %s from default-calendar and canEdit flags", async (accessRole, flags) => { + const api = new FakeCalendarListApi([ + page({ + items: [calendar({ id: accessRole, ...flags })], + }), + ]); + const { adapter } = adapterWith(api); + + const { calendars } = await adapter.discoverCalendars({ + accessToken: "at", + }); + + expect(calendars[0]?.accessRole).toBe(accessRole); + }); + + it("derives write and invite capabilities from canEdit", async () => { + const api = new FakeCalendarListApi([ + page({ + items: [ + calendar({ id: "writable", canEdit: true }), + calendar({ id: "readonly", canEdit: false }), + ], + }), + ]); + const { adapter } = adapterWith(api); + + const { calendars } = await adapter.discoverCalendars({ + accessToken: "at", + }); + const byId = Object.fromEntries( + calendars.map((entry) => [entry.providerCalendarId, entry.capabilities]), + ); + + expect(byId["writable"]).toEqual({ + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }); + expect(byId["readonly"]).toEqual({ + canReadEvents: true, + canWriteEvents: false, + canReadBusy: true, + canInviteAttendees: false, + }); + }); + + it("drops entries without an id and falls back displayName to the id", async () => { + const api = new FakeCalendarListApi([ + page({ + items: [ + calendar({ id: undefined, name: "no id" }), + calendar({ id: "bare-id", name: " " }), + ], + }), + ]); + const { adapter } = adapterWith(api); + + const { calendars } = await adapter.discoverCalendars({ + accessToken: "at", + }); + + expect(calendars).toHaveLength(1); + expect(calendars[0]?.displayName).toBe("bare-id"); + expect(calendars[0]?.eventLabels).toEqual([]); + expect(calendars[0]?.createsGoogleMeet).toBe(false); + }); + + it("maps a 401 to authExpired", async () => { + const api = new FakeCalendarListApi([], { response: { status: 401 } }); + const { adapter } = adapterWith(api); + + const error = (await adapter + .discoverCalendars({ accessToken: "stale" }) + .catch((caught) => caught)) as ProviderCalendarError; + + expect(error.reason).toBe("authExpired"); + }); + + it("maps a 429 to transient", async () => { + const api = new FakeCalendarListApi([], { response: { status: 429 } }); + const { adapter } = adapterWith(api); + + const error = (await adapter + .discoverCalendars({ accessToken: "at" }) + .catch((caught) => caught)) as ProviderCalendarError; + + expect(error.reason).toBe("transient"); + }); + + it("maps a 503 to transient", async () => { + const api = new FakeCalendarListApi([], { response: { status: 503 } }); + const { adapter } = adapterWith(api); + + const error = (await adapter + .discoverCalendars({ accessToken: "at" }) + .catch((caught) => caught)) as ProviderCalendarError; + + expect(error.reason).toBe("transient"); + }); + + it("maps other 4xx responses to discoveryFailed with triage facts", async () => { + const leaky = Object.assign(new Error("Access denied"), { + config: { + headers: { Authorization: "Bearer super-secret-access-token" }, + }, + response: { status: 403, data: { error: { code: "ErrorAccessDenied" } } }, + }); + const api = new FakeCalendarListApi([], leaky); + const { adapter } = adapterWith(api); + + const error = (await adapter + .discoverCalendars({ accessToken: "at" }) + .catch((caught) => caught)) as ProviderCalendarError; + + expect(error.reason).toBe("discoveryFailed"); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toContain("HTTP 403"); + expect((error.cause as { config?: unknown }).config).toBeUndefined(); + expect(JSON.stringify(error.cause)).not.toContain( + "super-secret-access-token", + ); + }); + + it("maps a network failure to transient", async () => { + const api = new FakeCalendarListApi([], new Error("socket hang up")); + const { adapter } = adapterWith(api); + + const error = (await adapter + .discoverCalendars({ accessToken: "at" }) + .catch((caught) => caught)) as ProviderCalendarError; + + expect(error.reason).toBe("transient"); + }); +}); diff --git a/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts new file mode 100644 index 000000000..41e3247af --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-calendar.adapter.ts @@ -0,0 +1,173 @@ +import { type CalendarAccessRole } from "@core/types/sync/connection.contracts"; +import { microsoftDiscoveredCalendarCapabilities } from "@sync/providers/microsoft/microsoft-calendar-capabilities"; +import { resolveMicrosoftCalendarColor } from "@sync/providers/microsoft/microsoft-calendar-colors"; +import { + isMicrosoftTransient, + microsoftFailureCause, + microsoftStatus, +} from "@sync/providers/microsoft/microsoft-error"; +import { + MICROSOFT_CALENDAR_LIST_SELECT, + MICROSOFT_GRAPH_BASE_URL, + MICROSOFT_REQUEST_TIMEOUT_MS, +} from "@sync/providers/microsoft/microsoft-http.constants"; +import { + type CalendarDiscovery, + type DiscoveredCalendar, + type ProviderCalendarAdapter, + ProviderCalendarError, +} from "@sync/providers/provider-calendar.port"; + +export interface MicrosoftGraphCalendar { + readonly id?: string; + readonly name?: string; + readonly color?: string; + readonly hexColor?: string; + readonly canEdit?: boolean; + readonly canShare?: boolean; + readonly isDefaultCalendar?: boolean; + readonly isRemovable?: boolean; + readonly owner?: { + readonly name?: string; + readonly address?: string; + }; +} + +export interface MicrosoftCalendarListPage { + readonly items: readonly MicrosoftGraphCalendar[]; + readonly nextLink: string | null; +} + +export interface MicrosoftCalendarListApi { + listPage(params: { nextLink?: string }): Promise; +} + +export type MicrosoftCalendarListApiFactory = ( + accessToken: string, +) => MicrosoftCalendarListApi; + +const defaultApiFactory: MicrosoftCalendarListApiFactory = (accessToken) => + new FetchMicrosoftCalendarListApi(accessToken); + +// Microsoft Graph implementation of the calendar-discovery port. Lists +// /me/calendars with @odata.nextLink paging and maps each row to provider-neutral +// facts. Graph has no calendar-list delta token, so discovery always returns a +// null cursor and relies on periodic re-list sweeps for drift. +export class MicrosoftCalendarAdapter implements ProviderCalendarAdapter { + #makeApi: MicrosoftCalendarListApiFactory; + + constructor(makeApi: MicrosoftCalendarListApiFactory = defaultApiFactory) { + this.#makeApi = makeApi; + } + + async discoverCalendars(input: { + accessToken: string; + cursor?: string; + }): Promise { + void input.cursor; + const api = this.#makeApi(input.accessToken); + const calendars: DiscoveredCalendar[] = []; + let nextLink: string | undefined; + + do { + const page = await this.#listPage(api, nextLink); + for (const item of page.items) { + const mapped = mapCalendar(item); + if (mapped) calendars.push(mapped); + } + nextLink = page.nextLink ?? undefined; + } while (nextLink); + + return { calendars, cursor: null }; + } + + async #listPage( + api: MicrosoftCalendarListApi, + nextLink?: string, + ): Promise { + try { + return await api.listPage({ nextLink }); + } catch (error) { + throw new ProviderCalendarError( + classifyDiscoveryError(error), + "Microsoft rejected the calendar list read", + { cause: microsoftFailureCause(error) }, + ); + } + } +} + +function mapCalendar(item: MicrosoftGraphCalendar): DiscoveredCalendar | null { + if (!item.id) return null; + + const canEdit = item.canEdit === true; + const accessRole = mapAccessRole(item.isDefaultCalendar === true, canEdit); + + return { + providerCalendarId: item.id, + displayName: item.name?.trim() || item.id, + color: resolveMicrosoftCalendarColor(item.hexColor, item.color), + eventLabels: [], + primary: item.isDefaultCalendar === true, + active: true, + accessRole, + capabilities: microsoftDiscoveredCalendarCapabilities(canEdit), + createsGoogleMeet: false, + }; +} + +function mapAccessRole( + isDefaultCalendar: boolean, + canEdit: boolean, +): CalendarAccessRole { + if (isDefaultCalendar) return "owner"; + if (canEdit) return "editor"; + return "viewer"; +} + +function classifyDiscoveryError( + error: unknown, +): "authExpired" | "transient" | "discoveryFailed" { + const status = microsoftStatus(error); + if (status === 401) return "authExpired"; + if (isMicrosoftTransient(error, status)) return "transient"; + return "discoveryFailed"; +} + +class FetchMicrosoftCalendarListApi implements MicrosoftCalendarListApi { + #accessToken: string; + + constructor(accessToken: string) { + this.#accessToken = accessToken; + } + + async listPage(params: { + nextLink?: string; + }): Promise { + const url = + params.nextLink ?? + `${MICROSOFT_GRAPH_BASE_URL}/me/calendars?$select=${MICROSOFT_CALENDAR_LIST_SELECT}`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${this.#accessToken}` }, + signal: AbortSignal.timeout(MICROSOFT_REQUEST_TIMEOUT_MS), + }); + + const data = (await response.json()) as { + value?: MicrosoftGraphCalendar[]; + "@odata.nextLink"?: string; + error?: { message?: string }; + }; + + if (!response.ok) { + throw Object.assign( + new Error(data.error?.message ?? "microsoft_calendar_list_failed"), + { response: { status: response.status, data } }, + ); + } + + return { + items: data.value ?? [], + nextLink: data["@odata.nextLink"] ?? null, + }; + } +} diff --git a/packages/sync/src/providers/microsoft/microsoft-error.ts b/packages/sync/src/providers/microsoft/microsoft-error.ts new file mode 100644 index 000000000..28f8b1709 --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-error.ts @@ -0,0 +1,25 @@ +import { redactedCause } from "@sync/safety/redact-error"; + +export function microsoftStatus(error: unknown): number | undefined { + const status = (error as { response?: { status?: number } })?.response + ?.status; + return typeof status === "number" ? status : undefined; +} + +export function isMicrosoftTransient( + error: unknown, + status: number | undefined = microsoftStatus(error), +): boolean { + if (status === undefined || status === 429 || status >= 500) return true; + return false; +} + +export function microsoftFailureCause(error: unknown): Error | undefined { + const status = microsoftStatus(error); + const facts = status === undefined ? [] : [`HTTP ${status}`]; + if (facts.length === 0) return redactedCause(error); + const message = error instanceof Error ? error.message : null; + return new Error( + message ? `${message} (${facts.join(", ")})` : facts.join(", "), + ); +} diff --git a/packages/sync/src/providers/microsoft/microsoft-http.constants.ts b/packages/sync/src/providers/microsoft/microsoft-http.constants.ts new file mode 100644 index 000000000..7e7d6bf7c --- /dev/null +++ b/packages/sync/src/providers/microsoft/microsoft-http.constants.ts @@ -0,0 +1,8 @@ +// Graph requests use fetch with no default timeout, so a hung socket blocks the +// job worker until the lease expires. 30s bounds the worst case while covering +// normal Graph latency. +export const MICROSOFT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"; +export const MICROSOFT_REQUEST_TIMEOUT_MS = 30_000; + +export const MICROSOFT_CALENDAR_LIST_SELECT = + "id,name,color,hexColor,canEdit,canShare,isDefaultCalendar,owner,isRemovable";