/** * Minimal Google Calendar v3 client. * * Hand-rolled fetch-only — no `googleapis` SDK. We use this for two narrow * operations: * 1. Exchange / refresh OAuth tokens. * 2. List events for a connected calendar account. * * Tokens themselves live in `app_secrets` (see `connect-calendar.ts` / * `sync-calendars.ts`); this module never persists them. * * Endpoints (Google has kept these stable since 2014): * Auth: https://accounts.google.com/o/oauth2/v2/auth * Token: https://oauth2.googleapis.com/token * Events: https://www.googleapis.com/calendar/v3/calendars/{calId}/events * Userinfo: https://www.googleapis.com/oauth2/v2/userinfo */ import { GOOGLE_PRIMARY_PROVIDER_CREDENTIAL_KEYS, resolveGoogleProviderCredentialCandidatesWithReader, } from "@agent-native/core/server"; export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; export const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; export const GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"; export const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"; export const GOOGLE_CALENDAR_SCOPES = [ "openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.events.readonly", ]; export interface GoogleTokenResponse { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string; scope?: string; id_token?: string; } export interface GoogleUserInfo { id: string; email: string; verified_email?: boolean; name?: string; picture?: string; } export interface CalendarEvent { id: string; status?: string; summary?: string; description?: string; location?: string; htmlLink?: string; hangoutLink?: string; start?: { dateTime?: string; date?: string; timeZone?: string }; end?: { dateTime?: string; date?: string; timeZone?: string }; organizer?: { email?: string; displayName?: string }; attendees?: Array<{ email?: string; displayName?: string; responseStatus?: string; self?: boolean; }>; conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string; label?: string; }>; }; updated?: string; recurringEventId?: string; } export interface ExchangeCodeArgs { code: string; clientId: string; clientSecret: string; redirectUri: string; } export interface GoogleOAuthClientCredentials { clientId: string; clientSecret: string; } export async function resolveGoogleOAuthCredentialCandidates(): Promise< GoogleOAuthClientCredentials[] > { return resolveGoogleProviderCredentialCandidatesWithReader({ readCredential: (key) => process.env[key], credentialKeyPairs: [GOOGLE_PRIMARY_PROVIDER_CREDENTIAL_KEYS], }); } /** Exchange an authorization code for tokens. Throws on non-2xx. */ export async function exchangeCode( args: ExchangeCodeArgs, ): Promise { const body = new URLSearchParams({ code: args.code, client_id: args.clientId, client_secret: args.clientSecret, redirect_uri: args.redirectUri, grant_type: "authorization_code", }); const res = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google token exchange failed (${res.status}): ${text}`); } return (await res.json()) as GoogleTokenResponse; } /** Refresh an access token. Throws on non-2xx (caller decides whether to drop the row). */ export async function refreshAccessToken(args: { refreshToken: string; clientId: string; clientSecret: string; }): Promise { const body = new URLSearchParams({ refresh_token: args.refreshToken, client_id: args.clientId, client_secret: args.clientSecret, grant_type: "refresh_token", }); const res = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google token refresh failed (${res.status}): ${text}`); } return (await res.json()) as GoogleTokenResponse; } /** * True when a refresh-token-endpoint failure is permanent (the refresh token * itself is dead — revoked, expired, or the OAuth client is wrong) rather * than transient (network error, 429, 5xx, timeout). Only permanent * failures should ever flip a calendar account to "needs-reauth"; transient * ones should be recorded as a sync error and retried on the next poll. */ export function isPermanentRefreshFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error || ""); const lower = message.toLowerCase(); return ( lower.includes("invalid_grant") || lower.includes("unauthorized_client") || lower.includes("invalid_client") ); } export async function refreshAccessTokenWithFallback(args: { refreshToken: string; credentials: GoogleOAuthClientCredentials[]; }): Promise { let lastError: unknown; for (const credentials of args.credentials) { try { return await refreshAccessToken({ refreshToken: args.refreshToken, clientId: credentials.clientId, clientSecret: credentials.clientSecret, }); } catch (error) { lastError = error; if (!isPermanentRefreshFailure(error)) throw error; } } throw lastError instanceof Error ? lastError : new Error("Google token refresh failed."); } /** Best-effort revoke. Returns true if Google returned 2xx, false otherwise. */ export async function revokeToken(token: string): Promise { try { const res = await fetch(GOOGLE_REVOKE_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }).toString(), }); return res.ok; } catch { return false; } } /** Fetch the user's basic profile so we can label the calendar account. */ export async function getUserInfo( accessToken: string, ): Promise { const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google userinfo failed (${res.status}): ${text}`); } return (await res.json()) as GoogleUserInfo; } export interface ListEventsArgs { accessToken: string; calendarId?: string; // defaults to "primary" timeMin?: string; // ISO timeMax?: string; // ISO maxResults?: number; singleEvents?: boolean; pageToken?: string; } export interface ListEventsResponse { items: CalendarEvent[]; nextPageToken?: string; } export interface GetEventArgs { accessToken: string; calendarId?: string; eventId: string; } /** Fetch a single event by id. Throws on non-2xx. */ export async function getEvent(args: GetEventArgs): Promise { const calId = encodeURIComponent(args.calendarId ?? "primary"); const eventId = encodeURIComponent(args.eventId); const url = `${GOOGLE_CALENDAR_API}/calendars/${calId}/events/${eventId}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${args.accessToken}` }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google calendar event failed (${res.status}): ${text}`); } return (await res.json()) as CalendarEvent; } /** Make a single events.list call (one page). Throws on non-2xx. */ async function listEventsPage( args: ListEventsArgs, ): Promise { const calId = encodeURIComponent(args.calendarId ?? "primary"); const params = new URLSearchParams(); if (args.timeMin) params.set("timeMin", args.timeMin); if (args.timeMax) params.set("timeMax", args.timeMax); params.set("maxResults", String(args.maxResults ?? 100)); params.set("singleEvents", args.singleEvents === false ? "false" : "true"); params.set("orderBy", "startTime"); if (args.pageToken) params.set("pageToken", args.pageToken); const url = `${GOOGLE_CALENDAR_API}/calendars/${calId}/events?${params.toString()}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${args.accessToken}` }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google calendar list failed (${res.status}): ${text}`); } const json = (await res.json()) as { items?: CalendarEvent[]; nextPageToken?: string; }; return { items: json.items ?? [], nextPageToken: json.nextPageToken, }; } /** * Hard cap on pages to fetch per `listEvents` call — protects against runaway * pagination loops if Google ever returns a stuck `nextPageToken`. With the * default `maxResults=250`, the cap allows up to 1250 events per sync window. */ const MAX_EVENT_PAGES = 5; /** * List events on a calendar, transparently following `nextPageToken` until the * page count cap is hit or the server stops returning a token. Throws on * non-2xx. Returns the merged list; `nextPageToken` is only included when the * cap was hit (so callers can decide whether to widen the time window). */ export async function listEvents( args: ListEventsArgs, ): Promise { const merged: CalendarEvent[] = []; let pageToken = args.pageToken; let lastNextToken: string | undefined; for (let page = 0; page < MAX_EVENT_PAGES; page++) { const res = await listEventsPage({ ...args, pageToken }); if (res.items?.length) merged.push(...res.items); lastNextToken = res.nextPageToken; if (!lastNextToken) break; pageToken = lastNextToken; } return { items: merged, // Only surface a token if we exited because of the page cap — otherwise the // caller fetched everything available in the requested window. nextPageToken: lastNextToken, }; } /** * Pick the conferencing join URL from an event. Prefers Google Meet's * `hangoutLink`, then a `video` conferenceData entry point, then any * uri shaped like a known meeting platform. */ export function pickJoinUrl(event: CalendarEvent): string | undefined { if (event.hangoutLink) return event.hangoutLink; const eps = event.conferenceData?.entryPoints ?? []; const video = eps.find((e) => e.entryPointType === "video"); if (video?.uri) return video.uri; for (const ep of eps) { if ( ep.uri && /(zoom\.us|meet\.google|teams\.microsoft|webex\.com)/i.test(ep.uri) ) { return ep.uri; } } // Fall back to scanning the description / location for a meeting URL. const haystack = `${event.description ?? ""}\n${event.location ?? ""}`; const m = haystack.match( /https?:\/\/(?:[a-z0-9-]+\.)?(?:zoom\.us|meet\.google\.com|teams\.microsoft\.com|webex\.com)\/[^\s<>"]+/i, ); return m?.[0]; } /** Detect the conferencing platform from an event's join URL. */ export function detectPlatform( joinUrl: string | undefined, ): "zoom" | "meet" | "teams" | "webex" | "other" { if (!joinUrl) return "other"; if (/meet\.google\.com/i.test(joinUrl)) return "meet"; if (/zoom\.us/i.test(joinUrl)) return "zoom"; if (/teams\.microsoft\.com/i.test(joinUrl)) return "teams"; if (/webex\.com/i.test(joinUrl)) return "webex"; return "other"; }