Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/adapters/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CalendarProviderName, string> = {
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
Expand Down Expand Up @@ -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}`)
Expand Down
34 changes: 27 additions & 7 deletions src/http/dashboard-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1941,11 +1952,20 @@ export function buildDashboardRoutes(ports: EnginePorts, slots: SlotService): Ap
*/
async function listCalendarsSafely(
connection: CalendarConnection,
): Promise<Array<{ id: string; name: string; primary: boolean }>> {
): 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 } : {}) }
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/http/pages/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1972,6 +1980,16 @@ function connectionCard(d: ConnectionsPageData, view: ConnectionView): string {
</article>`
}

// 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
? `<div role="alert">
<p class="pu-err" style="font-size:.9375rem;margin-top:.75rem">Could not list calendars from ${escapeHtml(providerLabel(c.provider))}.
Reconnecting will not help &mdash; ${escapeHtml(view.problem)}</p>
</div>`
: ''

// 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.
Expand Down Expand Up @@ -2007,6 +2025,7 @@ function connectionCard(d: ConnectionsPageData, view: ConnectionView): string {
// form, rendered after, which plain HTML honours without any script.
return `<article class="pu-card">
${connectionHeading(c)}
${problem}
<form id="save-${escapeHtml(c.id)}" method="post" action="/dashboard/connections/${id}">
${csrfField(d.csrf)}
<fieldset style="border:0;padding:0;margin:1rem 0 0">
Expand Down
32 changes: 32 additions & 0 deletions test/core/dashboard-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 78 additions & 1 deletion test/core/google-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(
Expand Down