diff --git a/.changeset/oauth-expiry-refresh-order.md b/.changeset/oauth-expiry-refresh-order.md new file mode 100644 index 0000000000..71c4bc2221 --- /dev/null +++ b/.changeset/oauth-expiry-refresh-order.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop the false "OAuth token expired" push notification on startup. +category: fix +dev: In ProjectEngine.start, OAuthRefreshScheduler.start() now runs before OAuthExpiryMonitor.start() so the proactive refresh renews a stale-but-refreshable access token before the refresh-blind monitor's first awaited check() reads `expires`. Ordering locked by an invocationCallOrder assertion in project-engine.test.ts. diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index ee29b66071..274740ca27 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -408,6 +408,14 @@ describe("ProjectEngine notification ownership wiring", () => { expect(mocks.oauthExpiryMonitorStart).toHaveBeenCalledTimes(1); expect(mocks.notifierStart).toHaveBeenCalledTimes(1); + // FNXC:ClaudeOAuth 2026-07-08-12:10: the proactive refresher must start BEFORE the + // refresh-blind expiry monitor's first (awaited) check, or a stale-but-refreshable + // access token fires a false "OAuth token expired" ntfy push on startup. Lock the order. + expect(mocks.oauthRefreshSchedulerStart).toHaveBeenCalledTimes(1); + expect(mocks.oauthRefreshSchedulerStart.mock.invocationCallOrder[0]).toBeLessThan( + mocks.oauthExpiryMonitorStart.mock.invocationCallOrder[0], + ); + await engine.stop(); expect(mocks.oauthExpiryMonitorStop).toHaveBeenCalledTimes(1); expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1); diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 730a3dea6b..60f73efb52 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -716,22 +716,34 @@ export class ProjectEngine { const oauthAlertState = new OAuthAlertStateStore({ statePath: getFusionOAuthAlertStatePath(), }); + /* + FNXC:ClaudeOAuth 2026-07-05-00:00: + FN-7574: proactively refresh OAuth access tokens ahead of expiry (widened window, + see OAUTH_REFRESH_BUFFER_MS in auth-storage.ts) so a healthy subscription session + never lapses waiting for something else to request a runtime API key. Reuses the + same authStorage instance as OAuthExpiryMonitor below so detection/notification and + proactive refresh observe a consistent, single credential source. + + FNXC:ClaudeOAuth 2026-07-08-12:10: + Start the proactive refresher BEFORE OAuthExpiryMonitor. Both are awaited on startup + and share this authStorage; the monitor's OAuthExpiryMonitor.start() runs its first + check() synchronously, and that check is refresh-blind (it only reads the stored + `expires` timestamp, with no refresh token or getApiKey in its interface). If the + monitor ran first, a stale-but-refreshable access token (the normal state after the + app has been closed a while) fired a false "OAuth token expired" ntfy push even + though the connection was fine — the refresher would silently renew the token moments + later. Refreshing first means the scheduler's awaited initial tick() renews the token + and the monitor (which reload()s authStorage at the top of check()) sees the fresh + `expires`, so the alarm only fires when refresh genuinely fails. + */ + this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage }); + await this.oauthRefreshScheduler.start(); this.oauthExpiryMonitor = new OAuthExpiryMonitor({ authStorage, notificationService: this.notificationService, alertState: oauthAlertState, }); await this.oauthExpiryMonitor.start(); - /* - FNXC:ClaudeOAuth 2026-07-05-00:00: - FN-7574: proactively refresh OAuth access tokens ahead of expiry (widened window, - see OAUTH_REFRESH_BUFFER_MS in auth-storage.ts) so a healthy subscription session - never lapses waiting for something else to request a runtime API key. Reuses the - same authStorage instance as OAuthExpiryMonitor above so detection/notification and - proactive refresh observe a consistent, single credential source. - */ - this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage }); - await this.oauthRefreshScheduler.start(); this.oauthValidityLogger = new OAuthValidityLogger({ authStorage, alertState: oauthAlertState,