fix: prevent false "OAuth token expired" push on startup

Start OAuthRefreshScheduler before the refresh-blind OAuthExpiryMonitor so a
stale-but-refreshable access token is renewed before the monitor's first
awaited check() reads `expires`. Previously the monitor fired a false
"OAuth token expired" ntfy push on startup, moments before the refresher
silently renewed the token. Ordering locked by an invocationCallOrder
assertion in project-engine.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 09:14:14 -07:00
parent 01be51bf35
commit a8c018f7d4
3 changed files with 37 additions and 10 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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,