From e3cdfa106726e14d4975215d10166056f03c0a6c Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 23 Jun 2026 10:03:56 -0400 Subject: [PATCH 1/3] fix(api): add daily cron to refresh OAuth tokens expiring within 24h --- .../refresh-expiring-tokens-schedule.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts new file mode 100644 index 000000000..6b928edab --- /dev/null +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts @@ -0,0 +1,105 @@ +import { db } from '@db'; +import { logger, schedules } from '@trigger.dev/sdk'; +import { requestValidCredentials } from './ensure-valid-credentials'; + +// Refresh tokens expiring within the next 24 hours +const REFRESH_LOOKAHEAD_HOURS = 24; + +/** + * Daily scheduled task that proactively refreshes OAuth tokens before they + * expire. Prevents the "OAuth token expired. Please reconnect" error caused by + * tokens expiring between scheduled check runs. + * + * Runs 1 hour before the daily integration checks (05:00 UTC vs 06:00 UTC) so + * tokens are always fresh when checks execute. + */ +export const refreshExpiringTokensSchedule = schedules.task({ + id: 'refresh-expiring-tokens-schedule', + cron: '0 5 * * *', // Daily at 05:00 UTC — 1 hour before integration checks + maxDuration: 60 * 30, // 30 minutes + run: async (payload) => { + logger.info('Starting proactive OAuth token refresh', { + scheduledAt: payload.timestamp, + lastRun: payload.lastTimestamp, + }); + + const apiUrl = process.env.API_URL; + if (!apiUrl) { + logger.error('API_URL environment variable is not set — cannot refresh tokens'); + return { refreshed: 0, failed: 0, skipped: 0 }; + } + + const lookaheadMs = REFRESH_LOOKAHEAD_HOURS * 60 * 60 * 1000; + const expiryThreshold = new Date(Date.now() + lookaheadMs); + + // Find all active connections whose latest credential version expires soon + const expiringConnections = await db.integrationConnection.findMany({ + where: { + status: 'active', + credentials: { + some: { + expiresAt: { + lte: expiryThreshold, + gt: new Date(), // Not already expired + }, + }, + }, + }, + include: { + organization: { select: { id: true, name: true } }, + credentials: { + orderBy: { version: 'desc' }, + take: 1, + select: { expiresAt: true }, + }, + }, + }); + + logger.info(`Found ${expiringConnections.length} connections with tokens expiring within ${REFRESH_LOOKAHEAD_HOURS}h`); + + let refreshed = 0; + let failed = 0; + let skipped = 0; + + for (const connection of expiringConnections) { + const expiresAt = connection.credentials[0]?.expiresAt; + const minutesUntilExpiry = expiresAt + ? Math.round((expiresAt.getTime() - Date.now()) / 60_000) + : null; + + logger.info(`Refreshing token for connection ${connection.id}`, { + provider: connection.providerSlug, + organizationId: connection.organizationId, + organizationName: connection.organization?.name, + minutesUntilExpiry, + }); + + const result = await requestValidCredentials({ + apiUrl, + connectionId: connection.id, + organizationId: connection.organizationId, + forceRefresh: true, + }); + + if (result.success) { + refreshed++; + logger.info(`Successfully refreshed token for connection ${connection.id}`); + } else { + failed++; + logger.warn(`Failed to refresh token for connection ${connection.id}`, { + error: result.error, + status: result.status, + }); + } + } + + logger.info('Proactive OAuth token refresh complete', { + total: expiringConnections.length, + refreshed, + failed, + skipped, + }); + + return { refreshed, failed, skipped, total: expiringConnections.length }; + }, +}); From 7375005f0bdb8bcc35b97555093323556791fb91 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 23 Jun 2026 11:01:15 -0400 Subject: [PATCH 2/3] fix(api): ensure only latest versions are considered in refresh-expiring-tokens-schedule --- .../refresh-expiring-tokens-schedule.spec.ts | 97 +++++++++++++++++++ .../refresh-expiring-tokens-schedule.ts | 34 ++++--- 2 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts new file mode 100644 index 000000000..ef18d679d --- /dev/null +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts @@ -0,0 +1,97 @@ +import { db } from '@db'; +import { requestValidCredentials } from './ensure-valid-credentials'; +import { refreshExpiringTokensSchedule } from './refresh-expiring-tokens-schedule'; + +jest.mock('@db', () => ({ + db: { + integrationConnection: { findMany: jest.fn() }, + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./ensure-valid-credentials', () => ({ + requestValidCredentials: jest.fn(), +})); + +describe('refreshExpiringTokensSchedule', () => { + const nowMs = Date.parse('2026-04-24T00:00:00.000Z'); + const lookaheadMs = 24 * 60 * 60 * 1000; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(nowMs); + (requestValidCredentials as jest.Mock).mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('refreshes only connections whose latest credential version expires soon', async () => { + const connectionWithOldVersionExpiringSoon = { + id: 'conn_old_soon', + providerSlug: 'example', + organizationId: 'org_1', + organization: { id: 'org_1', name: 'Org 1' }, + credentialVersions: [ + { expiresAt: new Date('2026-04-26T00:00:00.000Z') }, + { expiresAt: new Date('2026-04-24T12:00:00.000Z') }, + ], + }; + + const connectionWithLatestExpiringSoon = { + id: 'conn_latest_soon', + providerSlug: 'example', + organizationId: 'org_2', + organization: { id: 'org_2', name: 'Org 2' }, + credentialVersions: [{ expiresAt: new Date('2026-04-24T12:00:00.000Z') }], + }; + + (db.integrationConnection.findMany as jest.Mock).mockResolvedValue([ + connectionWithOldVersionExpiringSoon, + connectionWithLatestExpiringSoon, + ]); + + const result = await refreshExpiringTokensSchedule.run({ + timestamp: new Date(nowMs).toISOString(), + lastTimestamp: null, + } as any); + + expect(result.refreshed).toBe(1); + expect(requestValidCredentials).toHaveBeenCalledTimes(1); + expect(requestValidCredentials).toHaveBeenCalledWith({ + apiUrl: expect.any(String), + connectionId: 'conn_latest_soon', + organizationId: 'org_2', + forceRefresh: true, + }); + }); + + it('skips connections whose latest version is not expiring soon', async () => { + const connectionLatestValid = { + id: 'conn_latest_valid', + providerSlug: 'example', + organizationId: 'org_3', + organization: { id: 'org_3', name: 'Org 3' }, + credentialVersions: [{ expiresAt: new Date('2026-04-25T12:00:00.000Z') }], + }; + + (db.integrationConnection.findMany as jest.Mock).mockResolvedValue([ + connectionLatestValid, + ]); + + const result = await refreshExpiringTokensSchedule.run({ + timestamp: new Date(nowMs).toISOString(), + lastTimestamp: null, + } as any); + + expect(result.refreshed).toBe(0); + expect(requestValidCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts index 6b928edab..58b1298a6 100644 --- a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts @@ -29,25 +29,19 @@ export const refreshExpiringTokensSchedule = schedules.task({ return { refreshed: 0, failed: 0, skipped: 0 }; } + const now = new Date(); const lookaheadMs = REFRESH_LOOKAHEAD_HOURS * 60 * 60 * 1000; - const expiryThreshold = new Date(Date.now() + lookaheadMs); + const expiryThreshold = new Date(now.getTime() + lookaheadMs); - // Find all active connections whose latest credential version expires soon - const expiringConnections = await db.integrationConnection.findMany({ - where: { - status: 'active', - credentials: { - some: { - expiresAt: { - lte: expiryThreshold, - gt: new Date(), // Not already expired - }, - }, - }, - }, + // Find all active connections and check the expiry of the latest credential + // version. Older credential versions may exist, so a `some` predicate would + // incorrectly select connections where an older version is expiring while + // the latest version is still valid. + const activeConnections = await db.integrationConnection.findMany({ + where: { status: 'active' }, include: { organization: { select: { id: true, name: true } }, - credentials: { + credentialVersions: { orderBy: { version: 'desc' }, take: 1, select: { expiresAt: true }, @@ -55,6 +49,16 @@ export const refreshExpiringTokensSchedule = schedules.task({ }, }); + const expiringConnections = activeConnections.filter((connection) => { + const expiresAt = connection.credentialVersions[0]?.expiresAt; + return ( + expiresAt !== undefined && + expiresAt !== null && + expiresAt <= expiryThreshold && + expiresAt > now + ); + }); + logger.info(`Found ${expiringConnections.length} connections with tokens expiring within ${REFRESH_LOOKAHEAD_HOURS}h`); let refreshed = 0; From 9d30b3e754f643c7381c6e7c6716217a25382935 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 23 Jun 2026 11:10:53 -0400 Subject: [PATCH 3/3] fix(api): fix mismatched field issue in refresh-expiring-tokens-schedule --- .../integration-platform/refresh-expiring-tokens-schedule.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts index 58b1298a6..1b5e41abd 100644 --- a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts @@ -66,13 +66,12 @@ export const refreshExpiringTokensSchedule = schedules.task({ let skipped = 0; for (const connection of expiringConnections) { - const expiresAt = connection.credentials[0]?.expiresAt; + const expiresAt = connection.credentialVersions[0]?.expiresAt; const minutesUntilExpiry = expiresAt ? Math.round((expiresAt.getTime() - Date.now()) / 60_000) : null; logger.info(`Refreshing token for connection ${connection.id}`, { - provider: connection.providerSlug, organizationId: connection.organizationId, organizationName: connection.organization?.name, minutesUntilExpiry,