Skip to content
← Public packages

@kody/google

Call Gmail, Calendar, Tasks, Drive, Docs, Sheets, People, YouTube, and Analytics through saved Google OAuth.

src/scopes.ts

333 lines · 10.6 KB · TypeScript
export const SCOPE = {
	openid: 'openid',
	email: 'email',
	profile: 'profile',
	calendar: 'https://www.googleapis.com/auth/calendar',
	calendarReadonly: 'https://www.googleapis.com/auth/calendar.readonly',
	tasks: 'https://www.googleapis.com/auth/tasks',
	contacts: 'https://www.googleapis.com/auth/contacts',
	contactsReadonly: 'https://www.googleapis.com/auth/contacts.readonly',
	documents: 'https://www.googleapis.com/auth/documents',
	spreadsheets: 'https://www.googleapis.com/auth/spreadsheets',
	driveFile: 'https://www.googleapis.com/auth/drive.file',
	driveReadonly: 'https://www.googleapis.com/auth/drive.readonly',
	drive: 'https://www.googleapis.com/auth/drive',
	gmailSend: 'https://www.googleapis.com/auth/gmail.send',
	gmailReadonly: 'https://www.googleapis.com/auth/gmail.readonly',
	gmailCompose: 'https://www.googleapis.com/auth/gmail.compose',
	gmailModify: 'https://www.googleapis.com/auth/gmail.modify',
	youtubeReadonly: 'https://www.googleapis.com/auth/youtube.readonly',
	youtube: 'https://www.googleapis.com/auth/youtube',
	youtubeUpload: 'https://www.googleapis.com/auth/youtube.upload',
	youtubeForceSsl: 'https://www.googleapis.com/auth/youtube.force-ssl',
	ytAnalyticsReadonly: 'https://www.googleapis.com/auth/yt-analytics.readonly',
	analyticsReadonly: 'https://www.googleapis.com/auth/analytics.readonly',
} as const

/** Non-restricted Google scopes commonly granted on a personal OAuth client. */
export const BUILTIN_ALLOWED_SCOPES = new Set<string>([
	SCOPE.openid,
	SCOPE.email,
	SCOPE.profile,
	SCOPE.calendar,
	SCOPE.tasks,
	SCOPE.contacts,
	SCOPE.contactsReadonly,
	SCOPE.documents,
	SCOPE.spreadsheets,
	SCOPE.driveFile,
	SCOPE.gmailSend,
	SCOPE.youtubeReadonly,
])

export const GOOGLE_HOSTS = {
	www: 'www.googleapis.com',
	gmail: 'gmail.googleapis.com',
	people: 'people.googleapis.com',
	tasks: 'tasks.googleapis.com',
	docs: 'docs.googleapis.com',
	sheets: 'sheets.googleapis.com',
	youtube: 'youtube.googleapis.com',
	youtubeAnalytics: 'youtubeanalytics.googleapis.com',
	analytics: 'analyticsdata.googleapis.com',
	openid: 'openidconnect.googleapis.com',
	oauth: 'oauth2.googleapis.com',
	accounts: 'accounts.google.com',
} as const

export type ScopeLane = 'A' | 'B'

export type ScopeHint = {
	scope: string
	product: string
	lane: ScopeLane
	hosts: string[]
	why: string
}

type HintRule = {
	test: (url: string, method: string) => boolean
	hint: ScopeHint
}

function gmailUrl(url: string): boolean {
	return url.includes('gmail.googleapis.com') || url.includes('/gmail/v1/')
}

function driveUrl(url: string): boolean {
	return url.includes('/drive/v3/')
}

const HINT_RULES: HintRule[] = [
	{
		test: (url) => gmailUrl(url) && /\/drafts(?:\/|$|\?)/.test(url),
		hint: {
			scope: SCOPE.gmailCompose,
			product: 'Gmail drafts',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.gmail, GOOGLE_HOSTS.www],
			why: 'Creating or updating drafts needs gmail.compose or gmail.modify. Send-only clients that only have gmail.send cannot draft.',
		},
	},
	{
		test: (url) => gmailUrl(url) && /\/messages\/send(?:\/|$|\?)/.test(url),
		hint: {
			scope: SCOPE.gmailSend,
			product: 'Gmail send',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.gmail],
			why: 'Sending mail needs gmail.send on the Google OAuth client.',
		},
	},
	{
		test: (url) => gmailUrl(url),
		hint: {
			scope: SCOPE.gmailReadonly,
			product: 'Gmail inbox',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.gmail, GOOGLE_HOSTS.www],
			why: 'Reading the inbox (messages, threads, labels, attachments, profile) needs gmail.readonly on the Google OAuth client you register.',
		},
	},
	{
		test: (url) => url.includes('/calendar/'),
		hint: {
			scope: SCOPE.calendar,
			product: 'Google Calendar',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.www],
			why: 'Calendar needs the calendar scope on the Google OAuth client.',
		},
	},
	{
		test: (url) => driveUrl(url) && url.includes('/export'),
		hint: {
			scope: SCOPE.driveReadonly,
			product: 'Drive-wide export',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.www],
			why: 'Exporting a file the app did not create needs drive.readonly (or drive). drive.file only covers files this app created.',
		},
	},
	{
		test: (url) =>
			driveUrl(url) && (/\/files(?:\?|$)/.test(url) || url.includes('/files?') || /\/files$/.test(url.split('?')[0])),
		hint: {
			scope: SCOPE.driveReadonly,
			product: 'Drive-wide file list',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.www],
			why: 'Listing or searching the whole Drive needs drive.readonly (or drive). drive.file only covers files this app created.',
		},
	},
	{
		test: (url) => driveUrl(url),
		hint: {
			scope: SCOPE.driveFile,
			product: 'Drive (app-created files)',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.www],
			why: 'drive.file covers files this app created. Drive-wide access needs drive.readonly or drive.',
		},
	},
	{
		test: (url) => url.includes('people.googleapis.com') || url.includes('/v1/people'),
		hint: {
			scope: SCOPE.contactsReadonly,
			product: 'People / contacts',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.people],
			why: 'Contacts read needs contacts or contacts.readonly on the Google OAuth client.',
		},
	},
	{
		test: (url) => url.includes('tasks.googleapis.com') || url.includes('/tasks/v1/'),
		hint: {
			scope: SCOPE.tasks,
			product: 'Google Tasks',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.tasks],
			why: 'Tasks needs the tasks scope on the Google OAuth client.',
		},
	},
	{
		test: (url) => url.includes('docs.googleapis.com') || url.includes('/docs/v1/'),
		hint: {
			scope: SCOPE.documents,
			product: 'Google Docs',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.docs, GOOGLE_HOSTS.www],
			why: 'Docs needs the documents scope on the Google OAuth client. Approve host docs.googleapis.com if the call is blocked on hosts.',
		},
	},
	{
		test: (url) => url.includes('sheets.googleapis.com') || url.includes('/sheets/v4/'),
		hint: {
			scope: SCOPE.spreadsheets,
			product: 'Google Sheets',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.sheets, GOOGLE_HOSTS.www],
			why: 'Sheets needs the spreadsheets scope on the Google OAuth client. Approve host sheets.googleapis.com if the call is blocked on hosts.',
		},
	},
	{
		test: (url) => url.includes('analyticsdata.googleapis.com'),
		hint: {
			scope: SCOPE.analyticsReadonly,
			product: 'Google Analytics Data API',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.analytics, GOOGLE_HOSTS.www],
			why: 'GA4 runReport needs analytics.readonly on the Google OAuth client you register.',
		},
	},
	{
		test: (url) => url.includes('youtubeanalytics.googleapis.com') || url.includes('/v2/reports'),
		hint: {
			scope: SCOPE.ytAnalyticsReadonly,
			product: 'YouTube Analytics',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.youtubeAnalytics, GOOGLE_HOSTS.www],
			why: 'YouTube Analytics needs yt-analytics.readonly on the Google OAuth client you register.',
		},
	},
	{
		test: (url, method) =>
			url.includes('/youtube/v3/') && /upload|insert/i.test(url + method) && method !== 'GET',
		hint: {
			scope: SCOPE.youtubeUpload,
			product: 'YouTube upload',
			lane: 'B',
			hosts: [GOOGLE_HOSTS.www],
			why: 'Uploading or mutating YouTube videos needs youtube.upload / youtube / youtube.force-ssl, not youtube.readonly alone.',
		},
	},
	{
		test: (url) => url.includes('/youtube/v3/'),
		hint: {
			scope: SCOPE.youtubeReadonly,
			product: 'YouTube Data',
			lane: 'A',
			hosts: [GOOGLE_HOSTS.www],
			why: 'YouTube read needs youtube.readonly on the Google OAuth client.',
		},
	},
]

export function inferScopeHint(url: string, method = 'GET'): ScopeHint {
	const normalized = url || ''
	const httpMethod = (method || 'GET').toUpperCase()
	for (const rule of HINT_RULES) {
		if (rule.test(normalized, httpMethod)) return rule.hint
	}
	return {
		scope: SCOPE.calendar,
		product: 'Google API',
		lane: 'A',
		hosts: [GOOGLE_HOSTS.www],
		why: 'Reconnect Google and add the scope this endpoint documents on the OAuth client you register.',
	}
}

export function isBuiltinScope(scope: string): boolean {
	return BUILTIN_ALLOWED_SCOPES.has(scope)
}

export function isInsufficientScopeError(status: number, data: unknown): boolean {
	if (status !== 403 && status !== 401) return false
	const text = JSON.stringify(data ?? '').toLowerCase()
	return (
		text.includes('access_token_scope_insufficient') ||
		text.includes('insufficientpermissions') ||
		text.includes('insufficient authentication scopes') ||
		text.includes('insufficient permissions') ||
		(text.includes('insufficient') && text.includes('scope')) ||
		text.includes('request had insufficient authentication scopes')
	)
}

function encodeConnectQuery(params: Record<string, string>): string {
	return Object.entries(params)
		.map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
		.join('&')
}

export function builtinReconnectUrl(integrationName: string): string {
	return 'https://kody.codes/connect/oauth?provider=' + encodeURIComponent(integrationName)
}

export function byoConnectUrl(opts: {
	provider: string
	scopes: string[]
	hosts: string[]
}): string {
	const scopes = Array.from(
		new Set([SCOPE.openid, SCOPE.email, SCOPE.profile, ...opts.scopes]),
	)
	const hosts = Array.from(
		new Set([GOOGLE_HOSTS.www, GOOGLE_HOSTS.oauth, GOOGLE_HOSTS.openid, ...opts.hosts]),
	)
	return (
		'https://kody.codes/connect/oauth?' +
		encodeConnectQuery({
			provider: opts.provider,
			authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
			tokenUrl: 'https://oauth2.googleapis.com/token',
			flow: 'confidential',
			scopes: scopes.join(' '),
			allowedHosts: hosts.join(','),
			extraAuthorizeParams: JSON.stringify({ access_type: 'offline', prompt: 'consent' }),
		})
	)
}

export function formatInsufficientScopeError(opts: {
	integrationName: string
	url: string
	method: string
	status: number
	googleMessage: string | null
}): string {
	const hint = inferScopeHint(opts.url, opts.method)
	const reconnect = builtinReconnectUrl(opts.integrationName)
	const byo = byoConnectUrl({
		provider: opts.integrationName,
		scopes: [hint.scope],
		hosts: hint.hosts,
	})
	const googleBit = opts.googleMessage ? ' Google said: ' + opts.googleMessage : ''
	const lines = [
		`Google API ${opts.status} (insufficient scopes) for ${hint.product} on integration "${opts.integrationName}".`,
		`This call needs ${hint.scope}. ${hint.why}${googleBit}`,
		'',
	]
	lines.push(
		'Add that scope on the Google OAuth client you register, then reconnect:',
		reconnect,
		'',
		'New setup — Google Cloud Console → Web application client, redirect URI exactly https://kody.codes/connect/oauth. Publish the app to Production (Testing refresh tokens expire after 7 days). Open this prefilled connect URL and paste the client ID and client secret only on that form:',
		byo,
		'',
		'Load coding_guide_get guides "provider_google" and "google_oauth". Inbox and Drive-wide helpers stay in this package and need those scopes on the client.',
	)
	return lines.join('\n')
}