diff --git a/README.md b/README.md index 98c5e48..39369b6 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Architecture and research notes: - Fixed re-authentication having no effect until a manual instance restart: after completing the admin OAuth login the running poll loop kept using the previous (often expired) token, leaving `info.connection` false with no telemetry. The poll loop is now rebuilt with the new token immediately after re-auth (#175). - Fixed a single transient token refresh failure (network error, timeout, 5xx) blocking telemetry for up to 75 minutes: the refresh retry backoff is now classified per error. Rejected credentials (invalid_grant / other 4xx) still back off for an hour, but a transient failure only skips roughly the next poll (#178). - The underlying reason of a failed token refresh is now logged once as a warning, so it is visible even though `status.lastError` is later overwritten by the throttle message (#178). +- Fixed the token being refreshed on every poll: BLUETTI delivers the numeric OAuth fields (notably `expires_in`) as strings, which the token normalizer dropped, so no expiry could be derived and `isNearExpiry()` stayed true. Numeric strings are now coerced, restoring the #46 behaviour of refreshing only near real expiry (#178). ### 1.0.1 (2026-09-25) diff --git a/src/lib/bluetti-stored-token-provider.test.ts b/src/lib/bluetti-stored-token-provider.test.ts index 2cad0f8..fb3d8ed 100644 --- a/src/lib/bluetti-stored-token-provider.test.ts +++ b/src/lib/bluetti-stored-token-provider.test.ts @@ -4,6 +4,7 @@ import { expect } from 'chai'; // suffix is needed at runtime, while the main tsc config does not enable it. // @ts-expect-error Runtime import resolved by ts-node. import * as tokenProviderModule from './bluetti-stored-token-provider.ts'; +import type { BluettiOAuthToken } from './bluetti-stored-token-provider.ts'; const { BluettiStoredTokenProvider, @@ -165,6 +166,57 @@ describe('BluettiStoredTokenProvider', () => { expect(refreshCalls).to.equal(1); }); + it('accepts expires_in delivered as a numeric string and does not refresh on every poll (#178)', async () => { + let now = 1_000_000; // created_at stamped at load as floor(now / 1000) = 1000 s + let refreshCalls = 0; + const provider = new BluettiStoredTokenProvider({ + // Real BLUETTI shape: every numeric OAuth field arrives as a string. + oauthTokenJson: JSON.stringify({ + access_token: 'string-expires-access-token-secret', + refresh_token: 'string-expires-refresh-token-secret', + token_type: 'bearer', + expires_in: '3600', + }), + now: () => now, + refreshToken: () => { + refreshCalls++; + // Cast: the live wire response carries expires_in as a string, which the + // BluettiOAuthToken type intentionally does not model. + return Promise.resolve({ + access_token: 'refreshed-access-token-secret', + refresh_token: 'refreshed-refresh-token-secret', + token_type: 'bearer', + expires_in: '3600', + } as unknown as BluettiOAuthToken); + }, + persistToken: () => Promise.resolve(), + }); + + // The string lifetime is coerced, so an expiry is computable and no refresh is forced. + expect(provider.isTokenNearExpiry()).to.equal(false); + for (let poll = 0; poll < 5; poll++) { + now += 60_000; + expect(await provider.getAccessToken()).to.equal('string-expires-access-token-secret'); + } + expect(refreshCalls).to.equal(0); + }); + + it('drops a non-numeric expires_in instead of leaking the raw string through serialization', () => { + const parsed = parseStoredToken( + JSON.stringify({ + access_token: 'bogus-lifetime-access-token-secret', + refresh_token: 'bogus-lifetime-refresh-token-secret', + expires_in: 'not-a-number', + }), + () => 1_000_000, + ); + expect(parsed).to.not.equal(undefined); + const roundTripped = JSON.parse(stringifyToken(parsed as BluettiOAuthToken)); + expect(roundTripped).to.not.have.property('expires_in'); + // With no derivable lifetime, created_at is not stamped either. + expect(roundTripped).to.not.have.property('created_at'); + }); + it('refreshes after the cloud provider marks the token expired', async () => { let refreshCalls = 0; const provider = new BluettiStoredTokenProvider({ diff --git a/src/lib/bluetti-stored-token-provider.ts b/src/lib/bluetti-stored-token-provider.ts index 50d657d..9c1c7a2 100644 --- a/src/lib/bluetti-stored-token-provider.ts +++ b/src/lib/bluetti-stored-token-provider.ts @@ -220,17 +220,12 @@ function normalizeToken(value: unknown, now?: () => number): BluettiOAuthToken { token.refresh_token = value.refresh_token; } - if (typeof value.expires_at === 'number') { - token.expires_at = value.expires_at; - } - - if (typeof value.expires_in === 'number') { - token.expires_in = value.expires_in; - } - - if (typeof value.created_at === 'number') { - token.created_at = value.created_at; - } + // BLUETTI returns the numeric OAuth fields as strings ("3600"), so coerce numeric + // strings here — otherwise the raw string leaks through the spread above, no expiry + // can be derived and isNearExpiry() forces a refresh on every poll (#178). + setFiniteNumber(token, 'expires_at', value.expires_at); + setFiniteNumber(token, 'expires_in', value.expires_in); + setFiniteNumber(token, 'created_at', value.created_at); // BLUETTI's /oauth2/token response carries only a relative lifetime (expires_in), // with no created_at/expires_at. Without an issue timestamp getExpiresAtMs() cannot @@ -293,6 +288,23 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +// Assigns a numeric OAuth field, accepting both numbers and numeric strings ("3600"). +// Anything non-finite (missing, empty, non-numeric) clears the key so the raw string +// value copied by the spread in normalizeToken() cannot survive. +function setFiniteNumber( + token: BluettiOAuthToken, + key: 'expires_at' | 'expires_in' | 'created_at', + value: unknown, +): void { + const num = + typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN; + if (Number.isFinite(num)) { + token[key] = num; + } else { + delete token[key]; + } +} + function extractSafeErrorMessage(error: unknown): string { if (error instanceof Error) { return redactSensitiveText(error.message);