Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!-- markdownlint-disable-next-line MD024 -->
### 1.0.1 (2026-09-25)
Expand Down
52 changes: 52 additions & 0 deletions src/lib/bluetti-stored-token-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
34 changes: 23 additions & 11 deletions src/lib/bluetti-stored-token-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -293,6 +288,23 @@ function isObject(value: unknown): value is Record<string, unknown> {
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);
Expand Down
Loading