From 2f65fce648116ba61f62327e37efe391f85c7c2a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 14 Sep 2026 15:52:00 -0700 Subject: [PATCH] Tell the host to enable the calendar API, rather than showing an empty picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating OAuth credentials does not enable the Google Calendar API, so the first calendar a self-hoster connects returns 403 accessNotConfigured. On a 403 `expectOk` matched only insufficientPermissions/insufficient_scope, so this fell through to a generic CalendarApiError — and both listCalendars callers discarded it entirely. The result was a saved connection, an empty calendar picker, and nothing in the product or the log saying why. The obvious response to that is to reconnect, which cannot help: the grant is valid and the API is simply off. Reconnecting lands the host back on the same empty picker, so the failure reads as a bug in the product rather than a setting in their own console. Classify it as CalendarSetupRequiredError, deliberately distinct from CalendarReconnectRequiredError because the two need opposite advice, and put the console URL on the error itself — the fix is neither guessable from the status code nor performable inside Punctual. The connections page renders it and offers no Reconnect button, since that is the one action known not to work. Both call sites now log the reason instead of swallowing it; the grant is still kept on any failure, as before. --- src/adapters/oauth.ts | 66 +++++++++++++++++++++++ src/http/dashboard-routes.ts | 34 +++++++++--- src/http/pages/dashboard.ts | 19 +++++++ test/core/dashboard-pages.test.ts | 32 +++++++++++ test/core/google-integration.test.ts | 79 +++++++++++++++++++++++++++- 5 files changed, 222 insertions(+), 8 deletions(-) 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(