diff --git a/src/adapters/providers/gcalendar.ts b/src/adapters/providers/gcalendar.ts index 2d20d7b..d44a897 100644 --- a/src/adapters/providers/gcalendar.ts +++ b/src/adapters/providers/gcalendar.ts @@ -19,7 +19,20 @@ export const gcalendarManifest: ProviderManifest = { auth: { type: 'bearer' }, // Google answers 401 for a bad credential and keeps 403 for a disabled API // or a missing scope, neither of which reconnecting fixes. - errors: { forbidden: 'upstream_error' }, + errors: { + forbidden: 'upstream_error', + // Google spends 403 on two unrelated things: a scope this grant never got, + // and an API that was never enabled. Only the first is fixed by + // reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so. + // SERVICE_DISABLED is not in codes, so it keeps falling through to + // upstream_error. + bodyFailure: { + path: 'error.status', + equals: 'PERMISSION_DENIED', + codeFrom: 'error.errors.0.reason', + codes: { insufficientPermissions: 'reauth_required' }, + }, + }, pagination: { style: 'cursor', size: 25, diff --git a/src/adapters/providers/gdrive.ts b/src/adapters/providers/gdrive.ts index 7400e61..67a7fd8 100644 --- a/src/adapters/providers/gdrive.ts +++ b/src/adapters/providers/gdrive.ts @@ -19,7 +19,20 @@ export const gdriveManifest: ProviderManifest = { // every existing Google connection re-consents once either way. scopes: ['https://www.googleapis.com/auth/drive'], auth: { type: 'bearer' }, - errors: { forbidden: 'upstream_error' }, + errors: { + forbidden: 'upstream_error', + // Google spends 403 on two unrelated things: a scope this grant never got, + // and an API that was never enabled. Only the first is fixed by + // reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so. + // SERVICE_DISABLED is not in codes, so it keeps falling through to + // upstream_error. + bodyFailure: { + path: 'error.status', + equals: 'PERMISSION_DENIED', + codeFrom: 'error.errors.0.reason', + codes: { insufficientPermissions: 'reauth_required' }, + }, + }, pagination: { style: 'cursor', size: 25, diff --git a/src/adapters/providers/gmail.ts b/src/adapters/providers/gmail.ts index 5cb211a..a50e875 100644 --- a/src/adapters/providers/gmail.ts +++ b/src/adapters/providers/gmail.ts @@ -18,7 +18,20 @@ export const gmailManifest: ProviderManifest = { // Google answers 401 when the credential is bad. A 403 is something else, // most often an API that was never enabled on the project, and telling // someone to reconnect a healthy grant sends them the wrong way. - errors: { forbidden: 'upstream_error' }, + errors: { + forbidden: 'upstream_error', + // Google spends 403 on two unrelated things: a scope this grant never got, + // and an API that was never enabled. Only the first is fixed by + // reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so. + // SERVICE_DISABLED is not in codes, so it keeps falling through to + // upstream_error. + bodyFailure: { + path: 'error.status', + equals: 'PERMISSION_DENIED', + codeFrom: 'error.errors.0.reason', + codes: { insufficientPermissions: 'reauth_required' }, + }, + }, pagination: { style: 'cursor', size: 25, diff --git a/test/providers/google-workspace.test.ts b/test/providers/google-workspace.test.ts index 531a0cf..66ff9f0 100644 --- a/test/providers/google-workspace.test.ts +++ b/test/providers/google-workspace.test.ts @@ -71,6 +71,50 @@ describe('one google application, three prefixes', () => { }) }) +describe('a google grant that predates a scope widening', () => { + // Google's shape for both cases, differing only in reason. + const refusal = (reason: string) => ({ + error: { + code: 403, + status: 'PERMISSION_DENIED', + message: 'refused', + errors: [{ domain: 'global', reason, message: 'refused' }], + }, + }) + + it('tells a caller to reconnect when the grant is missing the scope', async () => { + for (const provider of [gmailProvider(), gcal, gdrive]) { + const upstream = fakeUpstream([ + { match: /./, status: 403, body: refusal('insufficientPermissions') }, + ]) + const err = await provider + .callTool(ctx(upstream), provider.listTools()[0]!.name, {}) + .catch((e) => e) + expect(err.code, provider.prefix).toBe('reauth_required') + } + }) + + it('still refuses to blame the credential when the API was never enabled', async () => { + // The case errors.forbidden exists for. Reconnecting cannot fix it, so it + // must not come back as reauth_required. + for (const provider of [gmailProvider(), gcal, gdrive]) { + const upstream = fakeUpstream([{ match: /./, status: 403, body: refusal('SERVICE_DISABLED') }]) + const err = await provider + .callTool(ctx(upstream), provider.listTools()[0]!.name, {}) + .catch((e) => e) + expect(err.code, provider.prefix).toBe('upstream_error') + } + }) + + it('leaves a 401 to the arm that already handles it', async () => { + const upstream = fakeUpstream([ + { match: /./, status: 401, body: { error: { code: 401, status: 'UNAUTHENTICATED' } } }, + ]) + const err = await gcal.callTool(ctx(upstream), 'list_calendars', {}).catch((e) => e) + expect(err.code).toBe('reauth_required') + }) +}) + describe('google calendar writes', () => { it('nests a start time under start.dateTime from a flat argument', async () => { // The manifest reaches into the body with a dotted param, and Calendar