← 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 · TypeScriptexport 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')
}