From 2be104ba34199a7946cccca03dab42ae69918041 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:04:11 +0200 Subject: [PATCH] fix(cloud-backup): mark dead Google tokens needs-reauth and surface reconnect in the UI (#970) Nightly cloud-backup syncs kept retrying Google connections whose refresh token is permanently dead (Google returns 400 invalid_grant; 3 of 12 prod connections are in this state), and the settings card showed the raw English error string while presenting the account as connected. - refreshAccessToken now throws a typed GoogleTokenRefreshError carrying status + body, with an isInvalidGrant discriminator. - performSync catches the invalid_grant case, persists status: 'needs_reauth' (+ needs_reauth_at) on the connection JSON in extension_data (no migration needed), and returns a needs_reauth failure instead of throwing. Transient failures (5xx, network, other 400s) still throw and stay retried. - The nightly cron loads connections for due companies and skips needs_reauth ones (reported as skipped in the summary) instead of retrying the dead token every night. A successful refresh clears a stale flag; reconnecting via OAuth writes a fresh connection. - CloudBackupCard shows a reconnect callout (Swedish-first, sv+en strings) wired to the existing connect action, and replaces the raw error string on the schedule row with a short reconnect notice. Co-authored-by: Claude Fable 5 --- .../auto-sync/cron/__tests__/route.test.ts | 166 ++++++++++++++- .../cloud-backup/auto-sync/cron/route.ts | 45 +++- .../components/CloudBackupCard.tsx | 52 ++++- extensions/general/cloud-backup/index.ts | 4 + .../lib/__tests__/google-oauth.test.ts | 30 +++ .../cloud-backup/lib/__tests__/sync.test.ts | 200 ++++++++++++++++++ .../general/cloud-backup/lib/google-oauth.ts | 28 ++- extensions/general/cloud-backup/lib/sync.ts | 33 ++- extensions/general/cloud-backup/types.ts | 11 + messages/en.json | 4 + messages/sv.json | 4 + 11 files changed, 562 insertions(+), 15 deletions(-) create mode 100644 extensions/general/cloud-backup/lib/__tests__/sync.test.ts diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts index 0ae768df..3b2c1c8a 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts @@ -7,6 +7,7 @@ vi.mock('@supabase/supabase-js', () => ({ vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({ performSync: vi.fn(), + CONNECTION_KEY: 'google_drive_connection', SCHEDULE_KEY: 'google_drive_schedule', saveExtensionData: vi.fn().mockResolvedValue(undefined), })) @@ -34,13 +35,44 @@ function makeRequest() { }) } -function makeSupabaseStub(rows: unknown[], error: unknown = null) { - const chain = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }), - } - return { from: vi.fn().mockReturnValue(chain) } as any +/** + * The route issues two queries against extension_data: schedules + * (key = google_drive_schedule) and connections (key = google_drive_connection, + * with an .in() filter). Route rows to the right result by the `key` eq filter. + */ +function makeSupabaseStub( + scheduleRows: unknown[], + options: { + scheduleError?: unknown + connectionRows?: unknown[] + connectionError?: unknown + } = {} +) { + const from = vi.fn().mockImplementation(() => { + let key: string | null = null + const chain: any = { + select: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + eq: vi.fn().mockImplementation((column: string, value: string) => { + if (column === 'key') key = value + return chain + }), + then: (resolve: (v: unknown) => void) => { + if (key === 'google_drive_connection') { + return resolve({ + data: options.connectionRows ?? [], + error: options.connectionError ?? null, + }) + } + return resolve({ + data: scheduleRows, + error: options.scheduleError ?? null, + }) + }, + } + return chain + }) + return { from } as any } describe('cloud-backup auto-sync cron', () => { @@ -224,4 +256,124 @@ describe('cloud-backup auto-sync cron', () => { expect((value as any).last_auto_sync_status).toBe('error') expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded') }) + + it('skips connections flagged needs_reauth without syncing or touching the schedule', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub( + [ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ], + { + connectionRows: [ + { company_id: 'c-1', value: { status: 'needs_reauth' } }, + ], + } + ) + ) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(mockPerformSync).not.toHaveBeenCalled() + expect(mockSaveExtensionData).not.toHaveBeenCalled() + expect(body.skipped).toBe(1) + expect(body.successes).toBe(0) + expect(body.errors).toBe(0) + expect(body.results).toEqual([ + { companyId: 'c-1', status: 'skipped', error: 'needs_reauth' }, + ]) + }) + + it('only skips the flagged company when others are due', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub( + [ + { + company_id: 'c-dead', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + { + company_id: 'c-live', + user_id: 'u-2', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ], + { + connectionRows: [ + { company_id: 'c-dead', value: { status: 'needs_reauth' } }, + { company_id: 'c-live', value: { status: 'active' } }, + ], + } + ) + ) + mockPerformSync.mockResolvedValueOnce({ + ok: true, + lastSync: { + at: '2026-07-10T03:00:00Z', + file_id: 'f-1', + file_name: 'arkiv.zip', + file_size_bytes: 1000, + folder_id: 'folder-1', + }, + webViewLink: 'https://drive.google.com/file/d/f-1/view', + }) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(mockPerformSync).toHaveBeenCalledTimes(1) + expect(mockPerformSync).toHaveBeenCalledWith( + expect.objectContaining({ companyId: 'c-live' }) + ) + expect(body.skipped).toBe(1) + expect(body.successes).toBe(1) + }) + + it('fails open and attempts the sync when the connection lookup errors', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub( + [ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ], + { connectionError: { message: 'connection query failed' } } + ) + ) + mockPerformSync.mockResolvedValueOnce({ + ok: false, + reason: 'needs_reauth', + message: 'Google Drive authorization expired; reconnect required', + }) + + const res = await GET(makeRequest()) + const body = await res.json() + + // performSync is still attempted (it re-flags dead tokens itself). + expect(mockPerformSync).toHaveBeenCalledTimes(1) + expect(body.errors).toBe(1) + }) }) diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts index 8d210437..786a1c52 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts @@ -4,10 +4,14 @@ import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { performSync, + CONNECTION_KEY, SCHEDULE_KEY, saveExtensionData, } from '@/extensions/general/cloud-backup/lib/sync' -import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/types' +import type { + GoogleDriveConnection, + GoogleDriveSchedule, +} from '@/extensions/general/cloud-backup/types' /** * GET /api/extensions/cloud-backup/auto-sync/cron @@ -73,6 +77,35 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques }) } + // Connections flagged needs_reauth carry a permanently dead refresh token + // (Google returned 400 invalid_grant): skip them instead of retrying every + // night. They stay visible in the UI until the user reconnects. + const { data: connectionRows, error: connectionError } = await supabase + .from('extension_data') + .select('company_id, value') + .eq('extension_id', 'cloud-backup') + .eq('key', CONNECTION_KEY) + .in( + 'company_id', + candidates.map((r) => r.company_id as string) + ) + + if (connectionError) { + // Fail open: without connection data we cannot tell who needs reauth, + // so fall back to attempting everyone (performSync re-flags dead tokens). + ctx.log.warn('failed to fetch connections for reauth check', { + message: connectionError.message, + }) + } + + const needsReauthCompanyIds = new Set( + (connectionRows ?? []) + .filter( + (r) => (r.value as GoogleDriveConnection | null)?.status === 'needs_reauth' + ) + .map((r) => r.company_id as string) + ) + const startTime = Date.now() const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit @@ -95,6 +128,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques const userId = row.user_id as string const schedule = row.value as GoogleDriveSchedule + if (needsReauthCompanyIds.has(companyId)) { + // Do not touch last_auto_sync_* here: the schedule keeps showing the + // failure from the night the dead token was detected. + results.push({ companyId, status: 'skipped', error: 'needs_reauth' }) + continue + } + try { const syncResult = await performSync({ supabase, @@ -141,11 +181,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques const successCount = results.filter((r) => r.status === 'success').length const errorCount = results.filter((r) => r.status === 'error').length + const skippedCount = results.filter((r) => r.status === 'skipped').length ctx.log.info('cloud backup cron summary', { processed: results.length, succeeded: successCount, failed: errorCount, + skipped: skippedCount, }) return NextResponse.json({ @@ -154,6 +196,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques processed: results.length, successes: successCount, errors: errorCount, + skipped: skippedCount, results, }) }) diff --git a/extensions/general/cloud-backup/components/CloudBackupCard.tsx b/extensions/general/cloud-backup/components/CloudBackupCard.tsx index 2c38e847..a1c36919 100644 --- a/extensions/general/cloud-backup/components/CloudBackupCard.tsx +++ b/extensions/general/cloud-backup/components/CloudBackupCard.tsx @@ -2,17 +2,19 @@ import { useCallback, useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' -import { Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react' +import { AlertTriangle, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react' import type { CloudBackupStatus, GoogleDriveSchedule } from '../types' const API_BASE = '/api/extensions/ext/cloud-backup' export default function CloudBackupCard() { const { toast } = useToast() + const t = useTranslations('extensions') const searchParams = useSearchParams() const [status, setStatus] = useState(null) @@ -118,6 +120,11 @@ export default function CloudBackupCard() { : 'Arkivet är för stort för direktsynk.' ) } + if (body.error === 'needs_reauth') { + // Refresh status so the card switches to the reconnect state. + await loadStatus() + throw new Error(t('ext_cloud_backup_reauth_description')) + } throw new Error(body.error || 'Synkningen misslyckades') } const { data } = (await res.json()) as { @@ -137,7 +144,7 @@ export default function CloudBackupCard() { } finally { setIsSyncing(false) } - }, [loadStatus, toast]) + }, [loadStatus, t, toast]) return (
@@ -161,6 +168,36 @@ export default function CloudBackupCard() {

Laddar…

) : status?.connected ? ( <> + {status.needs_reauth && ( +
+ +
+

+ {t('ext_cloud_backup_reauth_title')} +

+

+ {t('ext_cloud_backup_reauth_description')} +

+ +
+
+ )}
Konto
@@ -194,6 +231,7 @@ export default function CloudBackupCard() {
@@ -280,11 +318,13 @@ function formatDate(iso: string): string { interface ScheduleSectionProps { schedule: GoogleDriveSchedule | null + needsReauth: boolean onUpdated: () => Promise | void } -function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) { +function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) { const { toast } = useToast() + const t = useTranslations('extensions') // Convert stored UTC hour to the user's local hour for display. const initialLocalHour = @@ -400,7 +440,11 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) { ) : schedule.last_auto_sync_status === 'error' ? ( · misslyckades - {schedule.last_auto_sync_error ? ` (${schedule.last_auto_sync_error})` : ''} + {needsReauth + ? ` (${t('ext_cloud_backup_reauth_needed_short')})` + : schedule.last_auto_sync_error + ? ` (${schedule.last_auto_sync_error})` + : ''} ) : null}

diff --git a/extensions/general/cloud-backup/index.ts b/extensions/general/cloud-backup/index.ts index f024e666..3263f78d 100644 --- a/extensions/general/cloud-backup/index.ts +++ b/extensions/general/cloud-backup/index.ts @@ -180,6 +180,7 @@ export const cloudBackupExtension: Extension = { const schedule = await ctx.settings.get(SCHEDULE_KEY) const status: CloudBackupStatus = { connected: !!connection, + needs_reauth: connection?.status === 'needs_reauth', account_email: connection?.account_email ?? null, connected_at: connection?.connected_at ?? null, last_sync: lastSync ?? null, @@ -269,6 +270,9 @@ export const cloudBackupExtension: Extension = { if (result.reason === 'not_connected') { return jsonError('not_connected', 400) } + if (result.reason === 'needs_reauth') { + return jsonError('needs_reauth', 400) + } if (result.reason === 'archive_too_large') { return NextResponse.json( { diff --git a/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts b/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts index ecd3264c..0632dfc4 100644 --- a/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts +++ b/extensions/general/cloud-backup/lib/__tests__/google-oauth.test.ts @@ -4,6 +4,7 @@ import { exchangeCodeForTokens, refreshAccessToken, getOAuthEnv, + GoogleTokenRefreshError, } from '../google-oauth' beforeEach(() => { @@ -100,4 +101,33 @@ describe('refreshAccessToken', () => { const result = await refreshAccessToken(env, 'old-refresh') expect(result.access_token).toBe('new-at') }) + + it('throws GoogleTokenRefreshError carrying status and body on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"error":"invalid_grant"}', { status: 400 }) + ) + const env = getOAuthEnv('http://localhost:3000') + const err = await refreshAccessToken(env, 'dead-refresh').catch((e) => e) + expect(err).toBeInstanceOf(GoogleTokenRefreshError) + expect(err.status).toBe(400) + expect(err.body).toContain('invalid_grant') + expect(err.message).toContain('400') + }) +}) + +describe('GoogleTokenRefreshError.isInvalidGrant', () => { + it('is true only for a 400 whose body mentions invalid_grant', () => { + expect( + new GoogleTokenRefreshError(400, '{"error":"invalid_grant"}').isInvalidGrant + ).toBe(true) + expect( + new GoogleTokenRefreshError(400, '{"error":"invalid_request"}').isInvalidGrant + ).toBe(false) + expect( + new GoogleTokenRefreshError(500, '{"error":"invalid_grant"}').isInvalidGrant + ).toBe(false) + expect(new GoogleTokenRefreshError(503, 'Service Unavailable').isInvalidGrant).toBe( + false + ) + }) }) diff --git a/extensions/general/cloud-backup/lib/__tests__/sync.test.ts b/extensions/general/cloud-backup/lib/__tests__/sync.test.ts new file mode 100644 index 00000000..34b5d805 --- /dev/null +++ b/extensions/general/cloud-backup/lib/__tests__/sync.test.ts @@ -0,0 +1,200 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/reports/full-archive-export', () => ({ + estimateArchiveSize: vi.fn(), + generateFullArchive: vi.fn(), +})) + +vi.mock('../google-oauth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getOAuthEnv: vi.fn().mockReturnValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://app.test/callback', + }), + refreshAccessToken: vi.fn(), + } +}) + +vi.mock('../google-drive', () => ({ + ensureFolder: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('../crypto', () => ({ + decryptToken: vi.fn().mockReturnValue('plain-refresh-token'), +})) + +import { performSync, CONNECTION_KEY, LAST_SYNC_KEY } from '../sync' +import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth' +import { ensureFolder, uploadFile } from '../google-drive' +import { + estimateArchiveSize, + generateFullArchive, +} from '@/lib/reports/full-archive-export' +import type { GoogleDriveConnection } from '../../types' + +const mockRefreshAccessToken = vi.mocked(refreshAccessToken) +const mockEnsureFolder = vi.mocked(ensureFolder) +const mockUploadFile = vi.mocked(uploadFile) +const mockEstimateArchiveSize = vi.mocked(estimateArchiveSize) +const mockGenerateFullArchive = vi.mocked(generateFullArchive) + +function makeConnection( + overrides: Partial = {} +): GoogleDriveConnection { + return { + refresh_token_encrypted: 'encrypted-token', + account_email: 'user@example.com', + connected_at: '2026-01-01T00:00:00.000Z', + root_folder_id: 'root-1', + company_folder_id: 'company-1', + ...overrides, + } +} + +/** + * Minimal supabase stub covering what performSync touches: + * - extension_data select ... maybeSingle() (connection load) + * - extension_data upsert (connection/last-sync save) + * - company_settings select ... maybeSingle() (folder label) + */ +function makeSupabase(connection: GoogleDriveConnection | null) { + const upsert = vi.fn().mockResolvedValue({ error: null }) + const from = vi.fn().mockImplementation((table: string) => { + const chain: any = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: + table === 'extension_data' + ? connection + ? { value: connection } + : null + : { company_name: 'Testbolag AB', org_number: '556000-0000' }, + }), + upsert, + } + return chain + }) + return { supabase: { from } as any, upsert } +} + +function syncParams(supabase: any) { + return { + supabase, + companyId: 'company-1', + userId: 'user-1', + origin: 'https://app.test', + includeDocuments: true, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockEstimateArchiveSize.mockResolvedValue({ + total_bytes: 1_000, + document_bytes: 100, + } as any) +}) + +describe('performSync needs_reauth handling', () => { + it('flags the connection needs_reauth when Google returns 400 invalid_grant', async () => { + const { supabase, upsert } = makeSupabase(makeConnection()) + mockRefreshAccessToken.mockRejectedValueOnce( + new GoogleTokenRefreshError( + 400, + '{"error":"invalid_grant","error_description":"Token has been expired or revoked."}' + ) + ) + + const result = await performSync(syncParams(supabase)) + + expect(result).toMatchObject({ ok: false, reason: 'needs_reauth' }) + expect(upsert).toHaveBeenCalledTimes(1) + const [payload] = upsert.mock.calls[0] + expect(payload.key).toBe(CONNECTION_KEY) + expect(payload.value.status).toBe('needs_reauth') + expect(payload.value.needs_reauth_at).toEqual(expect.any(String)) + // Token, email etc. stay intact so the UI can still show the account. + expect(payload.value.account_email).toBe('user@example.com') + expect(mockGenerateFullArchive).not.toHaveBeenCalled() + expect(mockUploadFile).not.toHaveBeenCalled() + }) + + it('rethrows transient refresh failures (5xx) without flagging', async () => { + const { supabase, upsert } = makeSupabase(makeConnection()) + mockRefreshAccessToken.mockRejectedValueOnce( + new GoogleTokenRefreshError(500, 'Internal Server Error') + ) + + await expect(performSync(syncParams(supabase))).rejects.toThrow(/500/) + expect(upsert).not.toHaveBeenCalled() + }) + + it('rethrows a 400 that is not invalid_grant without flagging', async () => { + const { supabase, upsert } = makeSupabase(makeConnection()) + mockRefreshAccessToken.mockRejectedValueOnce( + new GoogleTokenRefreshError(400, '{"error":"invalid_request"}') + ) + + await expect(performSync(syncParams(supabase))).rejects.toThrow(/400/) + expect(upsert).not.toHaveBeenCalled() + }) + + it('rethrows non-refresh errors untouched', async () => { + const { supabase, upsert } = makeSupabase(makeConnection()) + mockRefreshAccessToken.mockRejectedValueOnce(new Error('network down')) + + await expect(performSync(syncParams(supabase))).rejects.toThrow('network down') + expect(upsert).not.toHaveBeenCalled() + }) + + it('clears a stale needs_reauth flag after a successful refresh', async () => { + const { supabase, upsert } = makeSupabase( + makeConnection({ + status: 'needs_reauth', + needs_reauth_at: '2026-07-01T03:00:00.000Z', + }) + ) + mockRefreshAccessToken.mockResolvedValueOnce({ + access_token: 'fresh-token', + expires_in: 3600, + }) + mockGenerateFullArchive.mockResolvedValueOnce(new ArrayBuffer(8)) + mockUploadFile.mockResolvedValueOnce({ + id: 'file-1', + name: 'arkiv.zip', + size_bytes: 8, + web_view_link: 'https://drive.google.com/file/d/file-1/view', + } as any) + + const result = await performSync(syncParams(supabase)) + + expect(result.ok).toBe(true) + // First upsert rewrites the connection with the flag cleared. + const connectionSave = upsert.mock.calls.find( + ([payload]) => payload.key === CONNECTION_KEY + ) + expect(connectionSave).toBeDefined() + expect(connectionSave![0].value.status).toBe('active') + // Last-sync state is still persisted as usual. + const lastSyncSave = upsert.mock.calls.find( + ([payload]) => payload.key === LAST_SYNC_KEY + ) + expect(lastSyncSave).toBeDefined() + }) + + it('still returns not_connected when no connection exists', async () => { + const { supabase } = makeSupabase(null) + + const result = await performSync(syncParams(supabase)) + + expect(result).toMatchObject({ ok: false, reason: 'not_connected' }) + expect(mockRefreshAccessToken).not.toHaveBeenCalled() + expect(mockEnsureFolder).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/cloud-backup/lib/google-oauth.ts b/extensions/general/cloud-backup/lib/google-oauth.ts index d531b5cf..791bbc4d 100644 --- a/extensions/general/cloud-backup/lib/google-oauth.ts +++ b/extensions/general/cloud-backup/lib/google-oauth.ts @@ -99,6 +99,32 @@ export interface AccessTokenResult { expires_in: number } +/** + * Thrown when Google's token endpoint rejects a refresh attempt. Carries the + * HTTP status and raw response body so callers can distinguish a permanently + * dead refresh token (400 invalid_grant) from transient failures. + */ +export class GoogleTokenRefreshError extends Error { + readonly status: number + readonly body: string + + constructor(status: number, body: string) { + super(`Google token refresh failed: ${status} ${body}`) + this.name = 'GoogleTokenRefreshError' + this.status = status + this.body = body + } + + /** + * True when Google reports the refresh token itself is dead (revoked, + * expired, or the grant was invalidated). Retrying will never succeed; + * the user must re-consent. + */ + get isInvalidGrant(): boolean { + return this.status === 400 && this.body.includes('invalid_grant') + } +} + export async function refreshAccessToken( env: OAuthEnv, refreshToken: string @@ -120,7 +146,7 @@ export async function refreshAccessToken( ) if (!res.ok) { const errText = await res.text() - throw new Error(`Google token refresh failed: ${res.status} ${errText}`) + throw new GoogleTokenRefreshError(res.status, errText) } return (await res.json()) as AccessTokenResult } diff --git a/extensions/general/cloud-backup/lib/sync.ts b/extensions/general/cloud-backup/lib/sync.ts index 5d882017..57ab16d9 100644 --- a/extensions/general/cloud-backup/lib/sync.ts +++ b/extensions/general/cloud-backup/lib/sync.ts @@ -6,6 +6,7 @@ import { import { getOAuthEnv, refreshAccessToken, + GoogleTokenRefreshError, } from './google-oauth' import { ensureFolder, uploadFile } from './google-drive' import { decryptToken } from './crypto' @@ -19,6 +20,7 @@ export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 export type SyncFailureReason = | 'not_connected' + | 'needs_reauth' | 'archive_too_large' | 'upload_failed' | 'internal' @@ -81,7 +83,30 @@ export async function performSync(params: PerformSyncParams): Promise