Google OAuth
Per-user Google consent - the requiresOAuth contract, scope gating, connection status, and test mocks.
On this page
The google/* endpoints (Gmail, Calendar, Drive, Contacts) are the platform's per-user OAuth surface: each signed-in user connects their own Google account, and the platform stores and auto-refreshes their tokens. Consent is incremental - users grant scopes one feature at a time, and the platform unions newly granted scopes with previously granted ones, so per-feature acquisition compounds instead of resetting.
This page is the contract for wiring google/* calls: building a "Connect Google" button, handling the requiresOAuth response, gating UI per scope, and mocking the OAuth surface in tests. For the general integration client, see external APIs and the integrations reference.
Billing must be 'user'#
The scaffold ships Google with billing: 'user'. That setting is non-negotiable for google/*:
// src/integrations.ts
export const integrations: Record<string, { billing: 'developer' | 'user' }> = {
google: { billing: 'user' }, // already in scaffold - never change to 'developer'
}
There is no separate auth-url endpoint#
To get an authUrl for a "Connect Google" button, POST the real endpoint whose scope you need. When the user isn't authorized, the response carries an authUrl built for exactly that endpoint's scopes; when they are, it carries the data. One code path serves both the connect button and the data load:
| Feature intent | Endpoint to POST |
|---|---|
| Calendar feature | google/calendar-list-events |
| Gmail read | google/gmail-list |
| Gmail compose | google/gmail-send |
| Drive feature | google/drive-list |
| Contacts feature | google/contacts-list |
Each google/* endpoint requests the scope set its feature needs - and every one of those sets is a single scope targeting exactly one Google API surface. Posting to google/calendar-list-events requests calendar events access only - it does not also request Gmail access, even if your app needs both. The set is per feature, not per verb: google/gmail-send requests gmail.modify, the platform's one Gmail write scope, so consenting from the Send button lights gmailModify too. On top of the feature scope, every auth URL always appends the openid / userinfo.email / userinfo.profile identity scopes. Scopes still accumulate one feature at a time through incremental consent; do not try to collect them all up front.
The requiresOAuth response is success-shaped#
When a google/* endpoint is called without stored tokens, without a required scope, or with a stored token Google rejects at call time, the api-worker returns the OAuth-required payload as a normal handler result - HTTP 200, wrapped in the standard success envelope:
{
success: true, // yes, true - this is not an error envelope
data: {
requiresOAuth: true,
provider: 'google',
scopes: string[], // the scopes this call needs
authUrl: string // send the user here to grant consent
}
}
One shape covers all three of those classes - no stored tokens, insufficient scope (Google 403), and a token Google rejects on use (401) - so a single check handles them. On that hard 401 the platform also clears the stale token row, so the next status poll honestly reports "not connected".
One revocation path does not take this shape. When the hour-scale access token has expired and the platform's refresh of it fails - the classic case: the user revoked the app in their Google account settings while idle - the refresh error surfaces as a plain integration failure: HTTP 502, success: false, error: 'upstream_provider_error', with a message beginning Token refresh failed. The token row is not cleared on this path, so connection status keeps reporting connected: true until the user re-consents. Treat that signature as an OAuth recovery case - surface the reconnect flow - not as a retryable provider error.
Client pattern - unwrap with data ?? result so the same code handles the nested envelope and any flattened variant:
const result = await integration.post('google/gmail-send', { to, subject, content })
if (!result.success) {
// Revoked while idle: a failed token refresh needs re-consent, not a retry.
if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt()
return
}
const payload = (result.data ?? result) as Record<string, unknown>
if (payload?.requiresOAuth && typeof payload.authUrl === 'string') {
window.open(payload.authUrl, 'google-auth', 'width=500,height=600')
// After the popup closes, refresh status and retry the call.
return
}
// Otherwise `payload` is the upstream Google response -
// e.g. for calendar-list-events, `payload.events` is the events array.
Apply this unwrap to every google/* call rather than reading result.requiresOAuth directly.
Gate per feature, never on a composite#
Gate each UI feature on its own scope flag. A composite isConnected boolean that ANDs multiple scopes creates a deadlock: the user grants calendar via the connect button, status reports calendar: true, gmailSend: false, the composite gate stays false, the UI shows "not connected", and the user can never reach the Send button that would request gmail.modify. The connection state is permanently stuck.
// ❌ Deadlock - user can never satisfy the gate one grant at a time
const isConnected = status?.google?.connected
&& status?.google?.calendar
&& status?.google?.gmailSend
if (!isConnected) return <ConnectGoogleButton />
// ✅ Per-feature gating - calendar UI appears the moment calendar is granted;
// the Gmail write scope is requested lazily when the user clicks Send.
{status?.google?.calendar && <EventsList onSendRecap={attemptSend} />}
async function attemptSend(to: string, subject: string, content: string) {
const result = await integration.post('google/gmail-send', { to, subject, content })
if (!result.success) {
if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt()
return
}
const payload = (result.data ?? result) as Record<string, unknown>
if (payload?.requiresOAuth && typeof payload.authUrl === 'string') {
window.open(payload.authUrl as string, 'google-auth', 'width=500,height=600')
// After the popup closes, refresh status and retry the send.
}
}
The rule: render each feature whose scope is granted; on actions that need a not-yet-granted scope, attempt the call - the requiresOAuth response carries an authUrl pre-built for the missing scope set - then retry after consent. Because the platform unions newly granted scopes with existing ones, per-feature acquisition compounds correctly.
Connection status#
GET /api/integrations/status (authenticated) returns per-scope flags so UIs can render accurate badges:
{
google: {
connected: boolean, // a Google account is linked
gmailSend: boolean, // can send mail
gmailRead: boolean, // can read mail
gmailModify: boolean, // can mutate the mailbox: archive, mark read/unread, trash
calendar: boolean,
drive: boolean,
contacts: boolean,
gmail: boolean, // aggregate: gmailSend || gmailRead
email?: string // connected account email, when known
}
}
Broader scopes imply narrower ones: a token granted gmail.modify (or full mail access) reports gmailSend and gmailRead as true automatically, and the broad calendar/drive/contacts scopes light their flags too. The implication runs one way only - a send-only gmail.send token does not light gmailModify - but the platform's own consent flow never mints such a token: google/gmail-send requests gmail.modify, so consenting through the Send flow lights all three Gmail flags at once. A send-only token can only predate that flow or come from a grant made elsewhere. Gate mailbox-mutation UI (archive, mark read/unread, trash) on gmailModify, not on gmail.
Use email for an "Acting as ..." chip without an extra round-trip.
Disconnect#
DELETE /api/integrations/oauth/google/disconnect (authenticated) revokes and clears the current user's stored Google tokens:
await fetch('/api/integrations/oauth/google/disconnect', {
method: 'DELETE',
headers: { Authorization: `Bearer ${await getAuthToken()}` },
})
Testing - mock the OAuth branches#
Real Google round-trips are deploy-and-manual only - automated tests must not perform live consent flows. Mock the OAuth surface with page.route(...) instead. The disconnected state is the easy half: fresh test accounts always show "Connect", so a smoke spec can assert it with no mocks. The branches that fail silently in production if untested are the connected-state UI and the requiresOAuth recovery prompt.
Minimum coverage, three recipes:
// 1. Connected state renders Disconnect + data UI
await page.route('**/api/integrations/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ google: { connected: true, calendar: true, gmailSend: true } }),
})
)
await page.route('**/api/integrations/google/calendar-list-events', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: { events: [{ id: 'e1', summary: 'mock event' }] },
}),
})
)
// → assert the Disconnect button is visible, the mock event renders, Send is enabled
// 2. requiresOAuth recovery - note the nested `data` envelope
await page.route('**/api/integrations/google/gmail-send', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
requiresOAuth: true,
provider: 'google',
// The real payload carries the endpoint's scope set as full URLs -
// for gmail-send that is gmail.modify, not gmail.send.
scopes: ['https://www.googleapis.com/auth/gmail.modify'],
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth?mock',
},
}),
})
)
// → assert the reconnect prompt appears, the page does not crash, no infinite retry loop
// 3. Disconnect hits the right endpoint
let disconnectCalled = false
await page.route('**/api/integrations/oauth/google/disconnect', (route) => {
disconnectCalled = true
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) })
})
// → click Disconnect, assert disconnectCalled === true, the banner flips back to "Connect"
Next steps#
- External APIs - the integration client, billing routing, and discovery.
- Integrations reference - envelope types and OAuth endpoints.
- Testing - the wider test discipline these mocks slot into.