diff --git a/src/adapters/oauth.ts b/src/adapters/oauth.ts index 2984d7b..cc544b9 100644 --- a/src/adapters/oauth.ts +++ b/src/adapters/oauth.ts @@ -172,6 +172,66 @@ export class CalendarReconnectRequiredError extends Error { } } +/** + * The provider's calendar API is not enabled for the operator's own cloud + * project. Deliberately NOT a `CalendarReconnectRequiredError`: the grant is + * valid and reconnecting cannot fix it, so offering "Reconnect" is worse than + * offering nothing — the host reconnects, lands back on an empty calendar + * list, and has no reason to suspect a console setting they have never seen. + * Google returns this as a 403 whose body names `accessNotConfigured`, which + * otherwise falls through to a generic `CalendarApiError` and gets logged as + * one more opaque failure. + * + * `howToFix` is part of the error because the fix is neither guessable from + * the status code nor performable inside this product. + */ +export class CalendarSetupRequiredError extends Error { + readonly provider: CalendarProviderName + /** Brand, so a cross-bundle `instanceof` miss cannot turn this into a 500. */ + readonly needsSetup = true as const + readonly howToFix: string + + constructor(provider: CalendarProviderName, detail: string) { + const howToFix = SETUP_HINTS[provider] + super(`[${provider}] calendar API is not enabled: ${detail}. ${howToFix}`) + this.name = 'CalendarSetupRequiredError' + this.provider = provider + this.howToFix = howToFix + } +} + +const SETUP_HINTS: Record = { + google: + 'Enable the Google Calendar API for the Cloud project that owns your OAuth client: ' + + 'https://console.cloud.google.com/apis/library/calendar-json.googleapis.com', + microsoft: + 'Grant the Calendars.ReadWrite application permission to your Entra app registration, ' + + 'then have a tenant admin consent to it.', +} + +/** + * Does this 403 mean "the API is switched off", rather than "this grant is not + * allowed to do that"? Pure, so the body shapes below are pinned by tests + * rather than discovered in production. + * + * Google phrases the same condition three ways depending on which surface + * answers — a `reason` of `accessNotConfigured`, a `SERVICE_DISABLED` status, + * or prose naming the project — and matching only one of them is how this + * reaches a host as a blank page. Deliberately not matching a bare "is + * disabled": that appears in unrelated 403s, and a false positive here tells + * a host to go change a console setting that was never the problem. + */ +export function isApiNotEnabled(body: string): boolean { + return /accessNotConfigured|SERVICE_DISABLED|has not been used in project/i.test(body) +} + +export function needsSetup(err: unknown): err is CalendarSetupRequiredError { + return ( + err instanceof CalendarSetupRequiredError || + (typeof err === 'object' && err !== null && (err as { needsSetup?: unknown }).needsSetup === true) + ) +} + /** Everything else. Carries status and body because "calendar sync failed" is not a bug report. */ export class CalendarApiError extends Error { readonly provider: CalendarProviderName @@ -413,6 +473,12 @@ export async function expectOk( if (res.status === 401) { throw new CalendarReconnectRequiredError(conn.provider, conn.id, `${what} returned 401: ${body}`) } + // Checked before the scope branch below because it is the more specific + // reading of a 403, and because the two call for opposite advice: this one + // must never tell the host to reconnect. + if (res.status === 403 && isApiNotEnabled(body)) { + throw new CalendarSetupRequiredError(conn.provider, `${what} returned 403: ${body}`) + } // Scopes were revoked or narrowed after the fact — also only the user can fix it. if (res.status === 403 && /insufficientPermissions|insufficient_scope|ErrorAccessDenied/i.test(body)) { throw new CalendarReconnectRequiredError(conn.provider, conn.id, `${what} returned 403: ${body}`) diff --git a/src/http/dashboard-routes.ts b/src/http/dashboard-routes.ts index 5580d49..ca10c15 100644 --- a/src/http/dashboard-routes.ts +++ b/src/http/dashboard-routes.ts @@ -73,7 +73,7 @@ import { validateSession, verifyManageToken, } from '../core/domain/auth-flows.js' -import { OAUTH_ENDPOINTS, scopesFor, type OAuthPurpose } from '../adapters/oauth.js' +import { OAUTH_ENDPOINTS, needsSetup, scopesFor, type OAuthPurpose } from '../adapters/oauth.js' import { dayRange } from '../engine.js' import { isValidTimeZone, localDateString } from '../core/time/zone.js' import { validateSlug } from '../core/domain/slugs.js' @@ -564,9 +564,19 @@ export function buildDashboardRoutes(ports: EnginePorts, slots: SlotService): Ap if (provider !== 'microsoft') connection.calendarIdsRead = [primary.id] connection.calendarIdWrite = primary.id } - } catch { + } catch (err) { // A provider having a bad minute must not lose a grant the host just // gave us. The connections page lets them pick calendars by hand. + // + // But swallowing the REASON is how a permanent misconfiguration — an + // un-enabled Calendar API, a scope the host declined on the granular + // consent screen — becomes an empty calendar picker with nothing + // anywhere to explain it. The grant still survives; the cause now + // reaches `wrangler tail`. + console.warn( + `[punctual] ${provider} listCalendars failed during connect; connection saved with no calendars selected:`, + err instanceof Error ? err.message : String(err), + ) } await repos.connections.create(connection) @@ -1879,7 +1889,8 @@ export function buildDashboardRoutes(ports: EnginePorts, slots: SlotService): Ap const views: ConnectionView[] = [] for (const connection of connections) { - views.push({ connection, calendars: await listCalendarsSafely(connection) }) + const { calendars, problem } = await listCalendarsSafely(connection) + views.push({ connection, calendars, ...(problem ? { problem } : {}) }) } return c.html( @@ -1941,11 +1952,20 @@ export function buildDashboardRoutes(ports: EnginePorts, slots: SlotService): Ap */ async function listCalendarsSafely( connection: CalendarConnection, - ): Promise> { + ): Promise<{ calendars: Array<{ id: string; name: string; primary: boolean }>; problem?: string }> { try { - return await ports.calendars.get(connection.provider).listCalendars(connection) - } catch { - return [] + return { calendars: await ports.calendars.get(connection.provider).listCalendars(connection) } + } catch (err) { + // The page must still render, but an empty picker with no cause given is + // indistinguishable from "this account genuinely has no calendars". + console.warn( + `[punctual] ${connection.provider} listCalendars failed for connection ${connection.id}:`, + err instanceof Error ? err.message : String(err), + ) + // Only a setup failure is shown to the host, because only its `howToFix` + // is written for them and actionable by them. A raw provider body on the + // page would be noise they cannot do anything about, so it stays in the log. + return { calendars: [], ...(needsSetup(err) ? { problem: err.howToFix } : {}) } } } diff --git a/src/http/pages/dashboard.ts b/src/http/pages/dashboard.ts index 1ee43e0..4fded73 100644 --- a/src/http/pages/dashboard.ts +++ b/src/http/pages/dashboard.ts @@ -1901,6 +1901,14 @@ export interface ConnectionView { * broken connection can still see and fix what is selected. */ calendars: Array<{ id: string; name: string; primary: boolean }> + /** + * Set when the list is empty because the provider's calendar API is not + * enabled for this deployment's cloud project — a misconfiguration the + * operator fixes in a console, not by reconnecting. Carries the fix itself, + * since an empty picker gives the host nothing to act on and the obvious + * guess (reconnect) leads straight back here. + */ + problem?: string } export interface ConnectionsPageData extends DashboardChrome { @@ -1972,6 +1980,16 @@ function connectionCard(d: ConnectionsPageData, view: ConnectionView): string { ` } + // Distinct from the `needs_reconnect` card above: that one offers Reconnect + // because reconnecting is the fix. Here it is not, so this deliberately + // offers no button at all — just the one thing that does work. + const problem = view.problem + ? `
+

Could not list calendars from ${escapeHtml(providerLabel(c.provider))}. + Reconnecting will not help — ${escapeHtml(view.problem)}

+
` + : '' + // A provider list we could not fetch must not silently drop the host's // selection, so fall back to the stored ids — labelled as ids we could // not resolve, so the host knows the name is missing and not the calendar. @@ -2007,6 +2025,7 @@ function connectionCard(d: ConnectionsPageData, view: ConnectionView): string { // form, rendered after, which plain HTML honours without any script. return `
${connectionHeading(c)} + ${problem}
${csrfField(d.csrf)}
diff --git a/test/core/dashboard-pages.test.ts b/test/core/dashboard-pages.test.ts index 8a5a11a..ccca727 100644 --- a/test/core/dashboard-pages.test.ts +++ b/test/core/dashboard-pages.test.ts @@ -95,6 +95,38 @@ describe('calendars page', () => { expect(html).not.toContain("Set the provider's") }) + it('explains an un-enabled calendar API instead of showing an empty picker', () => { + // The whole point of the message: the grant is fine, so the host's obvious + // move (reconnect) returns them right here. Say so, and say what does work. + const html = connectionsPage({ + ...chrome, + connections: [ + { + connection: connection(), + calendars: [], + problem: 'Enable the Google Calendar API for the Cloud project that owns your OAuth client: https://console.cloud.google.com/apis/library/calendar-json.googleapis.com', + }, + ], + availableProviders: ['google'], + }) + expect(html).toContain('Could not list calendars') + expect(html).toContain('Reconnecting will not help') + expect(html).toContain('console.cloud.google.com/apis/library/calendar-json.googleapis.com') + expect(html).toContain('role="alert"') + }) + + it('says nothing extra when the picker is simply empty', () => { + // No `problem` means we do not know why, and inventing a cause would send + // the host to a console setting that may be perfectly correct. + const html = connectionsPage({ + ...chrome, + connections: [{ connection: connection(), calendars: [] }], + availableProviders: ['google'], + }) + expect(html).not.toContain('Could not list calendars') + expect(html).not.toContain('Reconnecting will not help') + }) + it('offers only Reconnect and Disconnect on a connection that needs reconnecting', () => { const html = connectionsPage({ ...chrome, diff --git a/test/core/google-integration.test.ts b/test/core/google-integration.test.ts index 156e845..66c2cfd 100644 --- a/test/core/google-integration.test.ts +++ b/test/core/google-integration.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { createGoogleProvider } from '../../src/adapters/google/provider.js' -import { createEnvOAuthCredentials } from '../../src/adapters/oauth.js' +import { + createEnvOAuthCredentials, + isApiNotEnabled, + needsReconnect, + needsSetup, + type CalendarSetupRequiredError, +} from '../../src/adapters/oauth.js' import { computeSlots } from '../../src/core/slots/engine.js' import { combineBusy } from '../../src/core/domain/booking-service.js' import { localTimeToInstant } from '../../src/core/time/zone.js' @@ -356,6 +362,77 @@ describe('token lifecycle', () => { }) }) +/** + * The Calendar API being switched off in the operator's own Cloud project is a + * first-run failure, not an exotic one: creating OAuth credentials does not + * enable any API, so the very first deployment hits it. It matters that it is + * told apart from a revoked grant, because the two call for opposite advice — + * and "Reconnect" on a valid grant is a loop the host cannot escape or even + * diagnose. + * + * Body taken from Google's documented `accessNotConfigured` response. + */ +describe('un-enabled Calendar API', () => { + const ACCESS_NOT_CONFIGURED = { + error: { + code: 403, + message: + 'Google Calendar API has not been used in project 123456 before or it is disabled. ' + + 'Enable it by visiting https://console.developers.google.com/apis/api/calendar-json.googleapis.com/overview?project=123456 then retry.', + errors: [{ message: 'Access Not Configured.', domain: 'usageLimits', reason: 'accessNotConfigured' }], + status: 'PERMISSION_DENIED', + details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'SERVICE_DISABLED' }], + }, + } + + it('surfaces as needs-setup rather than needs-reconnect', async () => { + const { fetchImpl } = scriptGoogle([ + [/calendarList/, () => ({ status: 403, json: ACCESS_NOT_CONFIGURED })], + ]) + const provider = createGoogleProvider(deps(fetchImpl) as never) + + const err = await provider.listCalendars(connection()).then(() => null, (e: unknown) => e) + expect(needsSetup(err)).toBe(true) + // The distinction IS the fix: classify this as needs-reconnect and the host + // is sent to re-grant access that was never the problem. + expect(needsReconnect(err)).toBe(false) + }) + + it('carries a fix the host can actually act on', async () => { + const { fetchImpl } = scriptGoogle([ + [/calendarList/, () => ({ status: 403, json: ACCESS_NOT_CONFIGURED })], + ]) + const provider = createGoogleProvider(deps(fetchImpl) as never) + + const err = await provider.listCalendars(connection()).then(() => null, (e: unknown) => e) + // Not a status code and not a provider body — the console page to open. + expect((err as CalendarSetupRequiredError).howToFix).toContain( + 'console.cloud.google.com/apis/library/calendar-json.googleapis.com', + ) + }) + + it('classifies the three shapes Google reports it in, and nothing else', () => { + expect(isApiNotEnabled('"reason": "accessNotConfigured"')).toBe(true) + expect(isApiNotEnabled('"reason": "SERVICE_DISABLED"')).toBe(true) + expect(isApiNotEnabled('Google Calendar API has not been used in project 1 before')).toBe(true) + // A narrowed scope is a different failure with different advice: it really + // does need a reconnect, so it must not be absorbed here. + expect(isApiNotEnabled('{"error":"insufficientPermissions"}')).toBe(false) + expect(isApiNotEnabled('{"error":"invalid_grant"}')).toBe(false) + }) + + it('leaves a genuine scope failure classified as needs-reconnect', async () => { + const { fetchImpl } = scriptGoogle([ + [/calendarList/, () => ({ status: 403, json: { error: { message: 'insufficientPermissions' } } })], + ]) + const provider = createGoogleProvider(deps(fetchImpl) as never) + + const err = await provider.listCalendars(connection()).then(() => null, (e: unknown) => e) + expect(needsReconnect(err)).toBe(true) + expect(needsSetup(err)).toBe(false) + }) +}) + describe('OAuth configuration', () => { it('separates identity from calendar consent (ADR-0005 §1)', () => { const oauth = createEnvOAuthCredentials(