homebridge-tsvesync 1.1.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/dist/accessories/air-purifier.accessory.d.ts +1 -0
  3. package/dist/accessories/air-purifier.accessory.d.ts.map +1 -1
  4. package/dist/accessories/air-purifier.accessory.js +38 -26
  5. package/dist/accessories/air-purifier.accessory.js.map +1 -1
  6. package/dist/accessories/light.accessory.d.ts +25 -3
  7. package/dist/accessories/light.accessory.d.ts.map +1 -1
  8. package/dist/accessories/light.accessory.js +290 -14
  9. package/dist/accessories/light.accessory.js.map +1 -1
  10. package/dist/platform.d.ts +18 -0
  11. package/dist/platform.d.ts.map +1 -1
  12. package/dist/platform.js +224 -11
  13. package/dist/platform.js.map +1 -1
  14. package/dist/types/device.types.d.ts +16 -0
  15. package/dist/types/device.types.d.ts.map +1 -1
  16. package/dist/utils/api-proxy.d.ts +4 -1
  17. package/dist/utils/api-proxy.d.ts.map +1 -1
  18. package/dist/utils/api-proxy.js +5 -3
  19. package/dist/utils/api-proxy.js.map +1 -1
  20. package/dist/utils/device-factory.d.ts +1 -0
  21. package/dist/utils/device-factory.d.ts.map +1 -1
  22. package/dist/utils/device-factory.js +10 -2
  23. package/dist/utils/device-factory.js.map +1 -1
  24. package/dist/utils/session-store.d.ts +28 -0
  25. package/dist/utils/session-store.d.ts.map +1 -0
  26. package/dist/utils/session-store.js +92 -0
  27. package/dist/utils/session-store.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/accessories/air-purifier.accessory.ts +38 -22
  30. package/src/accessories/light.accessory.ts +415 -34
  31. package/src/platform.ts +223 -9
  32. package/src/types/device.types.ts +18 -0
  33. package/src/utils/api-proxy.ts +8 -2
  34. package/src/utils/device-factory.ts +16 -4
  35. package/src/utils/session-store.ts +92 -0
package/src/platform.ts CHANGED
@@ -6,6 +6,7 @@ import { BaseAccessory } from './accessories/base.accessory';
6
6
  import { PluginLogger } from './utils/logger';
7
7
  import { createRateLimitedVeSync } from './utils/api-proxy';
8
8
  import { PlatformConfig as TSVESyncPlatformConfig } from './types/device.types';
9
+ import { FileSessionStore, decodeJwtTimestampsLocal } from './utils/session-store';
9
10
 
10
11
  /**
11
12
  * HomebridgePlatform
@@ -24,6 +25,10 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
24
25
 
25
26
  private client!: VeSync;
26
27
  private deviceUpdateInterval?: NodeJS.Timeout;
28
+ private refreshTimer?: NodeJS.Timeout;
29
+ private refreshInProgress = false;
30
+ private scheduledExpMs: number | null = null;
31
+ private refreshRemainingMs: number | null = null;
27
32
  private readonly updateInterval!: number;
28
33
  private readonly debug!: boolean;
29
34
  private lastLoginAttempt: Date = new Date(0);
@@ -32,6 +37,7 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
32
37
  private initializationResolver!: () => void;
33
38
  private isInitialized = false;
34
39
  private readonly logger!: PluginLogger;
40
+ private readonly sessionStore!: FileSessionStore;
35
41
  // VeSync JWT tokens are valid for 30 days (verified by decoding the JWT)
36
42
  // We'll refresh at 25 days to ensure we never hit expiration
37
43
  private readonly TOKEN_EXPIRY = 25 * 24 * 60 * 60 * 1000; // 25 days in milliseconds
@@ -60,6 +66,9 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
60
66
  return;
61
67
  }
62
68
 
69
+ // Prepare session store
70
+ this.sessionStore = new FileSessionStore(this.api.user.storagePath(), this.logger);
71
+
63
72
  // Initialize VeSync client with all configuration
64
73
  this.client = createRateLimitedVeSync(
65
74
  config.username,
@@ -73,6 +82,10 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
73
82
  {
74
83
  countryCode: config.countryCode,
75
84
  quotaManagement: config.quotaManagement || { enabled: true }
85
+ },
86
+ {
87
+ store: this.sessionStore,
88
+ onTokenChange: (s) => this.onTokenChange(s)
76
89
  }
77
90
  );
78
91
 
@@ -91,6 +104,29 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
91
104
  this.logger.debug('Executed didFinishLaunching callback');
92
105
 
93
106
  try {
107
+ // Try to hydrate session from disk before any login
108
+ const session = await this.sessionStore.load();
109
+ if (session) {
110
+ try {
111
+ if ((session as any).username && (session as any).username !== this.config.username) {
112
+ this.logger.info('Found persisted session for a different account; ignoring persisted session.');
113
+ } else {
114
+ this.hydrateSessionCompat(session);
115
+ const ts = decodeJwtTimestampsLocal(session.token);
116
+ // Use actual token issuance time if available to avoid overextending lifetime
117
+ this.lastTokenRefresh = ts?.iat ? new Date(ts.iat * 1000) : new Date();
118
+ const expStr = ts?.exp ? new Date(ts.exp * 1000).toISOString() : 'unknown';
119
+ this.logger.info(`Reusing persisted VeSync session. Token exp: ${expStr}`);
120
+ // Schedule a proactive refresh before expiry
121
+ this.scheduleProactiveRefreshFromToken(session.token);
122
+ }
123
+ } catch (e: any) {
124
+ this.logger.debug(`Failed to hydrate persisted session, will login fresh: ${e?.message || e}`);
125
+ }
126
+ } else {
127
+ this.logger.debug('No persisted VeSync session available; will authenticate.');
128
+ }
129
+
94
130
  // Initialize platform
95
131
  await this.initializePlatform();
96
132
 
@@ -116,6 +152,9 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
116
152
  if (this.deviceUpdateInterval) {
117
153
  clearInterval(this.deviceUpdateInterval);
118
154
  }
155
+ if (this.refreshTimer) {
156
+ clearTimeout(this.refreshTimer);
157
+ }
119
158
  });
120
159
  }
121
160
 
@@ -146,12 +185,15 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
146
185
  */
147
186
  private async initializePlatform(): Promise<void> {
148
187
  try {
149
- // Try to login first
150
- if (!await this.ensureLogin()) {
151
- throw new Error('Failed to login to VeSync API');
188
+ // If we don't have a token/account yet, perform a login once. Otherwise,
189
+ // trust the persisted token and let the library re-login only if the API rejects it.
190
+ if (!(this.client as any).token || !(this.client as any).accountId) {
191
+ if (!await this.ensureLogin()) {
192
+ throw new Error('Failed to login to VeSync API');
193
+ }
152
194
  }
153
195
 
154
- // Get devices from API
196
+ // Get devices from API (will re-login on 401/419 only)
155
197
  await this.client.update();
156
198
 
157
199
  // Discover devices
@@ -255,6 +297,26 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
255
297
  // Reset backoff and update token refresh time on successful login
256
298
  this.loginBackoffTime = 10000;
257
299
  this.lastTokenRefresh = new Date();
300
+ // Best-effort: persist the fresh session immediately in case callbacks fail
301
+ try {
302
+ const token = (this.client as any).token as string | null;
303
+ const accountId = (this.client as any).accountId as string | null;
304
+ const region = (this.client as any).region as string | null;
305
+ const apiBaseUrl = (this.client as any).apiBaseUrl as string | null;
306
+ if (token && accountId && region && apiBaseUrl) {
307
+ const ts = decodeJwtTimestampsLocal(token);
308
+ await this.sessionStore.save({
309
+ token,
310
+ accountId,
311
+ region,
312
+ apiBaseUrl,
313
+ issuedAt: ts?.iat ?? null,
314
+ expiresAt: ts?.exp ?? null,
315
+ lastValidatedAt: Date.now(),
316
+ username: this.config.username,
317
+ } as any);
318
+ }
319
+ } catch {/* ignore */}
258
320
  isLoggedIn = true;
259
321
  return true;
260
322
  } catch (error) {
@@ -282,7 +344,162 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
282
344
  * Update device states periodically
283
345
  */
284
346
  private async updateDeviceStates() {
285
- await this.discoverDevices();
347
+ if (this.refreshInProgress) {
348
+ return;
349
+ }
350
+
351
+ this.refreshInProgress = true;
352
+
353
+ try {
354
+ await this.discoverDevices();
355
+
356
+ const syncTasks = Array.from(this.deviceAccessories.values()).map(async accessory => {
357
+ try {
358
+ await accessory.syncDeviceState();
359
+ } catch (error) {
360
+ this.logger.warn('Failed to sync device state during scheduled refresh', error as Error);
361
+ }
362
+ });
363
+
364
+ await Promise.all(syncTasks);
365
+ } finally {
366
+ this.refreshInProgress = false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Handle token updates from the library
372
+ */
373
+ private onTokenChange(session: { token: string } | undefined) {
374
+ if (!session?.token) return;
375
+ this.scheduleProactiveRefreshFromToken(session.token);
376
+ // Persist with username to ensure correct account reuse
377
+ try {
378
+ void this.sessionStore.save({ ...(session as any), username: this.config.username } as any);
379
+ } catch { /* noop */ }
380
+ }
381
+
382
+ /**
383
+ * Backward-compatible session hydration when using older tsvesync versions
384
+ */
385
+ private hydrateSessionCompat(session: { token: string; accountId: string; countryCode?: string | null; apiBaseUrl?: string; region?: string }) {
386
+ const client: any = this.client as any;
387
+ if (typeof client.hydrateSession === 'function') {
388
+ client.hydrateSession(session);
389
+ return;
390
+ }
391
+ // Fallback: set core fields directly
392
+ client.token = session.token;
393
+ client.accountId = session.accountId;
394
+ client.countryCode = session.countryCode ?? null;
395
+ if (session.apiBaseUrl) {
396
+ client.apiBaseUrl = session.apiBaseUrl;
397
+ }
398
+ if (session.region) {
399
+ try { client.region = session.region; } catch { /* ignore */ }
400
+ }
401
+ client.enabled = true;
402
+ }
403
+
404
+ /**
405
+ * Schedule a proactive token refresh before JWT expiry
406
+ */
407
+ private scheduleProactiveRefreshFromToken(token: string) {
408
+ try {
409
+ const ts = decodeJwtTimestampsLocal(token);
410
+ if (!ts?.exp) {
411
+ return; // Cannot schedule without exp
412
+ }
413
+ const now = Date.now();
414
+ const expMs = ts.exp * 1000;
415
+ const msToExpiry = expMs - now;
416
+
417
+ // If we already have a timer for this exact token expiration, skip
418
+ if (this.scheduledExpMs === expMs && this.refreshTimer) {
419
+ return;
420
+ }
421
+ if (msToExpiry <= 0) {
422
+ // Already expired; trigger immediate login in background
423
+ void this.ensureLogin(true);
424
+ return;
425
+ }
426
+
427
+ // Schedule policy to prevent thrash and avoid frequent logins:
428
+ // - If >7d left: refresh 5d before expiry
429
+ // - If 1–7d left: refresh 12h before expiry
430
+ // - If 1–24h left: refresh 1h before expiry
431
+ // - If <1h left: do not proactively refresh; rely on library's 401-triggered re-login
432
+ const ONE_HOUR = 60 * 60 * 1000;
433
+ const TWELVE_HOURS = 12 * ONE_HOUR;
434
+ const FIVE_DAYS = 5 * 24 * ONE_HOUR;
435
+ const SEVEN_DAYS = 7 * 24 * ONE_HOUR;
436
+
437
+ let refreshIn: number;
438
+ if (msToExpiry > SEVEN_DAYS) {
439
+ refreshIn = msToExpiry - FIVE_DAYS;
440
+ } else if (msToExpiry > 24 * ONE_HOUR) {
441
+ refreshIn = msToExpiry - TWELVE_HOURS;
442
+ } else if (msToExpiry > ONE_HOUR) {
443
+ refreshIn = msToExpiry - ONE_HOUR;
444
+ } else {
445
+ // Too close to expiry; avoid hammering login — let 401 path handle it
446
+ this.logger.debug('Token near expiry (<1h). Skipping proactive refresh; relying on auto re-login.');
447
+ return;
448
+ }
449
+
450
+ // Safety floor: never schedule earlier than 30 minutes from now
451
+ refreshIn = Math.max(refreshIn, 30 * 60 * 1000);
452
+
453
+ if (this.refreshTimer) {
454
+ clearTimeout(this.refreshTimer);
455
+ }
456
+ this.scheduledExpMs = expMs;
457
+ // Handle Node.js setTimeout max delay (~24.8 days). Chain timers when needed.
458
+ const MAX_DELAY = 0x7fffffff; // 2,147,483,647 ms
459
+ if (refreshIn > MAX_DELAY) {
460
+ this.refreshRemainingMs = refreshIn - MAX_DELAY;
461
+ this.logger.debug('Proactive refresh scheduled beyond setTimeout max; chaining timers.');
462
+ this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), MAX_DELAY);
463
+ } else {
464
+ this.refreshRemainingMs = 0;
465
+ this.refreshTimer = setTimeout(async () => {
466
+ if (this.refreshInProgress) {
467
+ this.logger.debug('Proactive refresh already in progress; skipping.');
468
+ return;
469
+ }
470
+ this.refreshInProgress = true;
471
+ this.logger.debug('Proactively refreshing VeSync session before token expiry');
472
+ try {
473
+ await this.ensureLogin(true);
474
+ } finally {
475
+ this.refreshInProgress = false;
476
+ }
477
+ }, refreshIn);
478
+ }
479
+
480
+ const hours = Math.round(refreshIn / (60 * 60 * 1000));
481
+ this.logger.debug(`Scheduled proactive token refresh in ~${hours}h`);
482
+ } catch (e) {
483
+ // Best-effort scheduling; ignore errors
484
+ }
485
+ }
486
+
487
+ private chainRefreshTimer() {
488
+ if (!this.refreshRemainingMs || this.refreshRemainingMs <= 0) {
489
+ // Final hop: trigger refresh now
490
+ if (this.refreshInProgress) {
491
+ this.logger.debug('Proactive refresh already in progress; skipping.');
492
+ return;
493
+ }
494
+ this.refreshInProgress = true;
495
+ this.logger.debug('Proactively refreshing VeSync session before token expiry');
496
+ void this.ensureLogin(true).finally(() => { this.refreshInProgress = false; });
497
+ return;
498
+ }
499
+ const MAX_DELAY = 0x7fffffff;
500
+ const hop = Math.min(this.refreshRemainingMs, MAX_DELAY);
501
+ this.refreshRemainingMs -= hop;
502
+ this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), hop);
286
503
  }
287
504
 
288
505
  /**
@@ -342,10 +559,7 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
342
559
  async discoverDevices() {
343
560
  this.logger.debug('Discovering devices');
344
561
  try {
345
- // Try to login first
346
- if (!await this.ensureLogin()) {
347
- return;
348
- }
562
+ // Do not force login; rely on library to re-login only if needed
349
563
 
350
564
  let retryCount = 0;
351
565
  let success = false;
@@ -22,6 +22,7 @@ export interface VeSyncDeviceWithPower extends VeSyncBaseDevice {
22
22
  deviceStatus: string;
23
23
  turnOn(): Promise<boolean>;
24
24
  turnOff(): Promise<boolean>;
25
+ getDetails?: (...args: any[]) => any;
25
26
  }
26
27
 
27
28
  export interface VeSyncDeviceWithBrightness extends VeSyncDeviceWithPower {
@@ -84,8 +85,25 @@ export interface VeSyncFan extends VeSyncDeviceWithPower, VeSyncDeviceWithSpeed,
84
85
  setMode(mode: 'normal' | 'auto' | 'sleep' | 'turbo'): Promise<boolean>;
85
86
  }
86
87
 
88
+ export interface VeSyncDimmerSwitch extends VeSyncDeviceWithBrightness {
89
+ rgbLightStatus: string;
90
+ indicatorLightStatus: string;
91
+ rgbLightValue: {
92
+ red: number;
93
+ green: number;
94
+ blue: number;
95
+ };
96
+ rgbColorSet(red: number, green: number, blue: number): Promise<boolean>;
97
+ rgbColorOn(): Promise<boolean>;
98
+ rgbColorOff(): Promise<boolean>;
99
+ indicatorLightOn(): Promise<boolean>;
100
+ indicatorLightOff(): Promise<boolean>;
101
+ }
102
+
87
103
  export interface VeSyncBulb extends VeSyncDeviceWithPower, VeSyncDeviceWithBrightness, VeSyncDeviceWithColor {}
88
104
 
105
+ export type VeSyncLightDevice = VeSyncBulb | VeSyncDimmerSwitch;
106
+
89
107
  export interface VeSyncOutlet extends VeSyncDeviceWithPower {
90
108
  power?: number;
91
109
  voltage?: number;
@@ -200,7 +200,7 @@ const createRateLimitedProxy = (target: any, rateLimiter: RateLimiter, deviceId?
200
200
  // Methods that bypass all rate limiting and debouncing
201
201
  const bypassMethods = [
202
202
  // Getters and internal methods
203
- 'get', 'getService', 'getCharacteristic',
203
+ 'get', 'getService', 'getCharacteristic', 'hydrateSession',
204
204
  // State accessors
205
205
  'deviceStatus', 'speed', 'brightness', 'colorTemp',
206
206
  'mode', 'filterLife', 'airQuality', 'airQualityValue', 'screenStatus',
@@ -258,6 +258,10 @@ export const createRateLimitedVeSync = (
258
258
  bufferPercentage?: number;
259
259
  priorityMethods?: string[];
260
260
  }
261
+ },
262
+ session?: {
263
+ store?: any;
264
+ onTokenChange?: (s: any) => void;
261
265
  }
262
266
  ): VeSync => {
263
267
  // Use the new options object pattern for VeSync constructor
@@ -267,7 +271,9 @@ export const createRateLimitedVeSync = (
267
271
  apiUrl: apiUrl,
268
272
  customLogger: customLogger,
269
273
  excludeConfig: exclusions,
270
- countryCode: config?.countryCode || 'US' // Default to US
274
+ countryCode: config?.countryCode || 'US', // Default to US
275
+ sessionStore: session?.store,
276
+ onTokenChange: session?.onTokenChange
271
277
  });
272
278
  const logger = customLogger || new PluginLogger(console as any, debug || false);
273
279
 
@@ -9,11 +9,12 @@ import { OutletAccessory } from '../accessories/outlet.accessory';
9
9
  import { SwitchAccessory } from '../accessories/switch.accessory';
10
10
  import { BaseAccessory } from '../accessories/base.accessory';
11
11
  import { AirQualitySensorAccessory } from '../accessories/air-quality-sensor.accessory';
12
- import {
12
+ import {
13
13
  VeSyncAirPurifier,
14
14
  VeSyncHumidifier,
15
15
  VeSyncFan,
16
16
  VeSyncBulb,
17
+ VeSyncDimmerSwitch,
17
18
  VeSyncOutlet,
18
19
  VeSyncSwitch
19
20
  } from '../types/device.types';
@@ -38,7 +39,9 @@ const OUTLET_MODELS = [
38
39
  'ESW10-USA', 'wifi-switch-1.3'
39
40
  ];
40
41
 
41
- const SWITCH_MODELS = ['ESWD16', 'ESWL01', 'ESWL03'];
42
+ const SWITCH_MODELS = ['ESWL01', 'ESWL03'];
43
+
44
+ const DIMMER_MODELS = ['ESWD16'];
42
45
 
43
46
  export class DeviceFactory {
44
47
  private static modelMatches(deviceType: string, models: string[]): boolean {
@@ -66,6 +69,10 @@ export class DeviceFactory {
66
69
  return deviceType.startsWith('ESL') || deviceType === 'XYD0001';
67
70
  }
68
71
 
72
+ private static isDimmer(deviceType: string): boolean {
73
+ return this.modelMatches(deviceType, DIMMER_MODELS);
74
+ }
75
+
69
76
  private static isOutlet(deviceType: string): boolean {
70
77
  return this.modelMatches(deviceType, OUTLET_MODELS);
71
78
  }
@@ -96,6 +103,11 @@ export class DeviceFactory {
96
103
  return new FanAccessory(platform, accessory, device as VeSyncFan);
97
104
  }
98
105
 
106
+ // Dimmers
107
+ if (this.isDimmer(deviceType)) {
108
+ return new LightAccessory(platform, accessory, device as VeSyncDimmerSwitch);
109
+ }
110
+
99
111
  // Bulbs
100
112
  if (this.isBulb(deviceType)) {
101
113
  return new LightAccessory(platform, accessory, device as VeSyncBulb);
@@ -131,7 +143,7 @@ export class DeviceFactory {
131
143
  return Categories.FAN;
132
144
  }
133
145
 
134
- if (this.isBulb(type)) {
146
+ if (this.isDimmer(type) || this.isBulb(type)) {
135
147
  return Categories.LIGHTBULB;
136
148
  }
137
149
 
@@ -179,4 +191,4 @@ export class DeviceFactory {
179
191
  platform.log.debug(`Not creating AQ sensor for ${device.deviceName} - no air quality support detected`);
180
192
  return null;
181
193
  }
182
- }
194
+ }
@@ -0,0 +1,92 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { PluginLogger } from './logger';
4
+
5
+ export interface PluginSession {
6
+ token: string;
7
+ accountId: string;
8
+ countryCode?: string | null;
9
+ region: string;
10
+ apiBaseUrl: string;
11
+ authFlowUsed?: 'legacy' | 'new';
12
+ issuedAt?: number | null;
13
+ expiresAt?: number | null;
14
+ lastValidatedAt?: number | null;
15
+ libraryVersion?: string;
16
+ // Optional: helps ensure session matches configured Homebridge account
17
+ username?: string;
18
+ }
19
+
20
+ export class FileSessionStore {
21
+ private readonly dir: string;
22
+ private readonly file: string;
23
+
24
+ constructor(basePath: string, private readonly logger: PluginLogger) {
25
+ this.dir = path.join(basePath, 'tsvesync');
26
+ this.file = path.join(this.dir, 'session.json');
27
+ }
28
+
29
+ async load(): Promise<PluginSession | null> {
30
+ try {
31
+ await fs.promises.mkdir(this.dir, { recursive: true });
32
+ const data = await fs.promises.readFile(this.file, 'utf8');
33
+ const session = JSON.parse(data) as PluginSession;
34
+ if (!session || !session.token || !session.accountId) return null;
35
+ try {
36
+ const expSec = session.expiresAt && session.expiresAt > 1e11 ? Math.floor(session.expiresAt / 1000) : session.expiresAt;
37
+ const exp = expSec ? new Date(expSec * 1000).toISOString() : 'unknown';
38
+ this.logger.debug(`Loaded persisted session from ${this.file} (exp: ${exp})`);
39
+ } catch {}
40
+ return session;
41
+ } catch (e: any) {
42
+ if (e?.code === 'ENOENT') {
43
+ this.logger.debug(`No persisted session found at ${this.file}`);
44
+ } else {
45
+ this.logger.debug(`Session load error: ${e?.message || e}`);
46
+ }
47
+ return null;
48
+ }
49
+ }
50
+
51
+ async save(session: PluginSession): Promise<void> {
52
+ try {
53
+ await fs.promises.mkdir(this.dir, { recursive: true });
54
+ const tmp = this.file + '.tmp';
55
+ await fs.promises.writeFile(tmp, JSON.stringify(session), { encoding: 'utf8', mode: 0o600 });
56
+ await fs.promises.rename(tmp, this.file);
57
+ try { await fs.promises.chmod(this.file, 0o600); } catch { /* best effort */ }
58
+ try {
59
+ const expSec = session.expiresAt && session.expiresAt > 1e11 ? Math.floor(session.expiresAt / 1000) : session.expiresAt;
60
+ const exp = expSec ? new Date(expSec * 1000).toISOString() : 'unknown';
61
+ this.logger.debug(`Persisted session to ${this.file} (exp: ${exp})`);
62
+ } catch {}
63
+ } catch (e: any) {
64
+ this.logger.debug(`Session save error: ${e?.message || e}`);
65
+ }
66
+ }
67
+
68
+ async clear(): Promise<void> {
69
+ try {
70
+ await fs.promises.unlink(this.file);
71
+ } catch (e: any) {
72
+ if (e?.code !== 'ENOENT') {
73
+ this.logger.debug(`Session clear error: ${e?.message || e}`);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ export function decodeJwtTimestampsLocal(token: string): { iat?: number; exp?: number } | null {
80
+ try {
81
+ const parts = token.split('.');
82
+ if (parts.length !== 3) return null;
83
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
84
+ let iat = typeof payload.iat === 'number' ? payload.iat : undefined;
85
+ let exp = typeof payload.exp === 'number' ? payload.exp : undefined;
86
+ if (iat && iat > 1e11) iat = Math.floor(iat / 1000);
87
+ if (exp && exp > 1e11) exp = Math.floor(exp / 1000);
88
+ return { iat, exp };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }