homebridge-salus-cloud 0.1.2 → 0.1.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.
- package/README.md +8 -0
- package/dist/salusCloudClient.d.ts +25 -0
- package/dist/salusCloudClient.js +771 -21
- package/dist/salusCloudClient.js.map +1 -1
- package/package.json +1 -1
package/dist/salusCloudClient.js
CHANGED
|
@@ -20,9 +20,15 @@ const DEFAULT_EU_SERVICE_API_HOST = 'https://service-api.eu.premium.salusconnect
|
|
|
20
20
|
const DEFAULT_US_SERVICE_API_HOST = 'https://service-api.us.premium.salusconnect.io';
|
|
21
21
|
const FALLBACK_US_SERVICE_API_HOST = 'https://service-api.us.salusconnect.io';
|
|
22
22
|
const FALLBACK_EU_SERVICE_API_HOST = 'https://service-api.eu.salusconnect.io';
|
|
23
|
+
const DEFAULT_EU_LEGACY_API_HOST = 'https://eu.premium.salusconnect.io';
|
|
24
|
+
const DEFAULT_US_LEGACY_API_HOST = 'https://us.premium.salusconnect.io';
|
|
25
|
+
const FALLBACK_US_LEGACY_API_HOST = 'https://us.salusconnect.io';
|
|
26
|
+
const FALLBACK_EU_LEGACY_API_HOST = 'https://eu.salusconnect.io';
|
|
23
27
|
const COGNITO_INITIATE_AUTH_TARGET = 'AWSCognitoIdentityProviderService.InitiateAuth';
|
|
24
28
|
const ACCEPT_LANGUAGE = 'en-US,en;q=0.9,en;q=0.8';
|
|
25
29
|
const SESSION_REFRESH_SAFETY_MS = 60_000;
|
|
30
|
+
const LEGACY_SESSION_REFRESH_SAFETY_MS = 60_000;
|
|
31
|
+
const LEGACY_DEFAULT_SESSION_TTL_MS = 45 * 60_000;
|
|
26
32
|
const STATUS_ALLOW_PATH_FALLBACK = new Set([404, 405, 426]);
|
|
27
33
|
const STATUS_ALLOW_WRITE_SHAPE_FALLBACK = new Set([400, 404, 405, 409, 415, 422]);
|
|
28
34
|
const DEFAULT_EXPECTED_STATUSES = [200, 201, 202, 204];
|
|
@@ -37,6 +43,22 @@ const RETRIABLE_ERROR_CODES = new Set([
|
|
|
37
43
|
'UND_ERR_CONNECT_TIMEOUT',
|
|
38
44
|
'UND_ERR_SOCKET',
|
|
39
45
|
]);
|
|
46
|
+
const DEFAULT_COMPANY_CODE_FALLBACKS = [
|
|
47
|
+
'SALUS',
|
|
48
|
+
'SALUS_US',
|
|
49
|
+
'HEATLINK_US',
|
|
50
|
+
'MRPEX_US',
|
|
51
|
+
'NEOTHERM_EU',
|
|
52
|
+
'OMNIE_EU',
|
|
53
|
+
'PURMO',
|
|
54
|
+
'CLP_SG',
|
|
55
|
+
'CLP',
|
|
56
|
+
'HEATLINK',
|
|
57
|
+
'MRPEX',
|
|
58
|
+
'NEOTHERM',
|
|
59
|
+
'OMNIE',
|
|
60
|
+
];
|
|
61
|
+
const NO_COMPANY_CODE_SENTINEL = '__none__';
|
|
40
62
|
const METADATA_FIELD_NAMES = new Set([
|
|
41
63
|
'id',
|
|
42
64
|
'key',
|
|
@@ -56,6 +78,7 @@ const METADATA_FIELD_NAMES = new Set([
|
|
|
56
78
|
'reported',
|
|
57
79
|
'desired',
|
|
58
80
|
'version',
|
|
81
|
+
'value',
|
|
59
82
|
'gateway',
|
|
60
83
|
'gateway_id',
|
|
61
84
|
'user_id',
|
|
@@ -66,16 +89,25 @@ export class SalusCloudClient {
|
|
|
66
89
|
config;
|
|
67
90
|
session = null;
|
|
68
91
|
authRequestInFlight = null;
|
|
92
|
+
legacySession = null;
|
|
93
|
+
legacyAuthRequestInFlight = null;
|
|
69
94
|
requestTimeoutMs;
|
|
70
95
|
maxRetries;
|
|
71
96
|
retryBaseDelayMs;
|
|
72
97
|
verboseLogging;
|
|
73
98
|
allowInsecureTls;
|
|
74
99
|
serviceApiBaseCandidates;
|
|
100
|
+
legacyApiBaseCandidates;
|
|
75
101
|
cognitoEndpoint;
|
|
76
102
|
cognitoClientId;
|
|
77
103
|
configuredCompanyCode;
|
|
104
|
+
companyCodeCandidates = [];
|
|
105
|
+
activeCompanyCode = null;
|
|
106
|
+
hasWarnedAboutAuthCompanyCode = false;
|
|
78
107
|
activeServiceApiBaseUrl = null;
|
|
108
|
+
activeLegacyApiBaseUrl = null;
|
|
109
|
+
apiTransportMode = 'modern';
|
|
110
|
+
hasWarnedAboutLegacyFallback = false;
|
|
79
111
|
propertyCacheByDsn = new Map();
|
|
80
112
|
deviceIdToDsn = new Map();
|
|
81
113
|
deviceKeyToDsn = new Map();
|
|
@@ -94,22 +126,44 @@ export class SalusCloudClient {
|
|
|
94
126
|
this.verboseLogging = config.verboseLogging ?? false;
|
|
95
127
|
this.allowInsecureTls = config.allowInsecureTls ?? false;
|
|
96
128
|
this.serviceApiBaseCandidates = buildServiceApiBaseCandidates(config.region, config.apiHost, config.apiVersionPreference);
|
|
129
|
+
this.legacyApiBaseCandidates = buildLegacyApiBaseCandidates(config.region, config.apiHost);
|
|
97
130
|
const cognitoRegion = normalizeNonEmptyString(config.cognitoRegion) ?? DEFAULT_COGNITO_REGION;
|
|
98
131
|
this.cognitoClientId = normalizeNonEmptyString(config.cognitoClientId) ?? DEFAULT_COGNITO_CLIENT_ID;
|
|
99
132
|
this.cognitoEndpoint = `https://cognito-idp.${cognitoRegion}.amazonaws.com/`;
|
|
100
133
|
this.configuredCompanyCode = normalizeNonEmptyString(config.companyCode) ?? null;
|
|
134
|
+
this.refreshCompanyCodeCandidates();
|
|
101
135
|
if (this.allowInsecureTls) {
|
|
102
136
|
this.log.warn('TLS certificate validation is disabled for Salus cloud requests (allowInsecureTls=true).');
|
|
103
137
|
}
|
|
104
138
|
if (this.verboseLogging) {
|
|
105
139
|
this.log.debug(`Salus service-api candidates: ${this.serviceApiBaseCandidates.join(', ')}`);
|
|
140
|
+
this.log.debug(`Salus legacy-api candidates: ${this.legacyApiBaseCandidates.join(', ')}`);
|
|
106
141
|
this.log.debug(`Salus Cognito endpoint: ${this.cognitoEndpoint}`);
|
|
142
|
+
this.log.debug(`Salus company-code candidates: ${this.companyCodeCandidates.map((candidate) => candidate ?? '<none>').join(', ')}`);
|
|
107
143
|
}
|
|
108
144
|
}
|
|
109
145
|
getCloudBaseUrl() {
|
|
146
|
+
if (this.apiTransportMode === 'legacy') {
|
|
147
|
+
return this.activeLegacyApiBaseUrl ?? this.legacyApiBaseCandidates[0];
|
|
148
|
+
}
|
|
110
149
|
return this.activeServiceApiBaseUrl ?? this.serviceApiBaseCandidates[0];
|
|
111
150
|
}
|
|
112
151
|
async listDevices() {
|
|
152
|
+
if (this.apiTransportMode === 'legacy') {
|
|
153
|
+
return await this.listDevicesLegacy();
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
return await this.listDevicesModern();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!shouldSwitchToLegacyApi(error)) {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
this.switchToLegacyTransport(`Modern Salus API authorization failed: ${asErrorMessage(error)}`);
|
|
163
|
+
return await this.listDevicesLegacy();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async listDevicesModern() {
|
|
113
167
|
const payload = await this.requestServiceJsonWithPathFallback(['/devices/', '/devices'], {
|
|
114
168
|
method: 'GET',
|
|
115
169
|
auth: true,
|
|
@@ -138,11 +192,45 @@ export class SalusCloudClient {
|
|
|
138
192
|
}
|
|
139
193
|
return devices;
|
|
140
194
|
}
|
|
195
|
+
async listDevicesLegacy() {
|
|
196
|
+
const payload = await this.requestLegacyJsonWithPathFallback(['/apiv1/devices.json', '/apiv1/devices', '/apiv1/registered_nodes.json'], {
|
|
197
|
+
method: 'GET',
|
|
198
|
+
auth: true,
|
|
199
|
+
});
|
|
200
|
+
const devices = parseDevices(payload);
|
|
201
|
+
this.rebuildDeviceIndex(devices);
|
|
202
|
+
const inlineShadows = parseDeviceShadows(payload, this.deviceIdToDsn, this.deviceKeyToDsn);
|
|
203
|
+
if (inlineShadows.size > 0) {
|
|
204
|
+
this.mergeIntoPropertyCache(inlineShadows);
|
|
205
|
+
if (this.verboseLogging) {
|
|
206
|
+
this.log.debug(`Hydrated property cache from legacy /apiv1/devices response for ${inlineShadows.size} device(s)`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (this.verboseLogging) {
|
|
210
|
+
this.log.debug(`Salus legacy cloud returned ${devices.length} device(s)`);
|
|
211
|
+
}
|
|
212
|
+
return devices;
|
|
213
|
+
}
|
|
141
214
|
async listProperties(dsn) {
|
|
142
215
|
const cached = this.propertyCacheByDsn.get(dsn);
|
|
143
216
|
if (cached) {
|
|
144
217
|
return cached;
|
|
145
218
|
}
|
|
219
|
+
if (this.apiTransportMode === 'legacy') {
|
|
220
|
+
return await this.listPropertiesLegacy(dsn);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return await this.listPropertiesModern(dsn);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!shouldSwitchToLegacyApi(error)) {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
this.switchToLegacyTransport(`Modern Salus property sync failed for ${dsn}: ${asErrorMessage(error)}`);
|
|
230
|
+
return await this.listPropertiesLegacy(dsn);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async listPropertiesModern(dsn) {
|
|
146
234
|
const shadows = await this.fetchDeviceShadows([], [dsn]);
|
|
147
235
|
const fromFetch = shadows.get(dsn);
|
|
148
236
|
if (fromFetch) {
|
|
@@ -154,7 +242,43 @@ export class SalusCloudClient {
|
|
|
154
242
|
}
|
|
155
243
|
return new Map();
|
|
156
244
|
}
|
|
245
|
+
async listPropertiesLegacy(dsn) {
|
|
246
|
+
const encodedDsn = encodeURIComponent(dsn);
|
|
247
|
+
const payload = await this.requestLegacyJsonWithPathFallback([
|
|
248
|
+
`/apiv1/dsns/${encodedDsn}/properties.json`,
|
|
249
|
+
`/apiv1/dsns/${encodedDsn}/properties`,
|
|
250
|
+
], {
|
|
251
|
+
method: 'GET',
|
|
252
|
+
auth: true,
|
|
253
|
+
});
|
|
254
|
+
const parsed = parseProperties(payload);
|
|
255
|
+
if (parsed.size > 0) {
|
|
256
|
+
this.propertyCacheByDsn.set(dsn, parsed);
|
|
257
|
+
return parsed;
|
|
258
|
+
}
|
|
259
|
+
if (this.verboseLogging) {
|
|
260
|
+
this.log.debug(`No legacy cloud property payload found for dsn=${dsn}. Returning empty property map.`);
|
|
261
|
+
}
|
|
262
|
+
return new Map();
|
|
263
|
+
}
|
|
157
264
|
async setDatapoint(dsn, propertyName, value) {
|
|
265
|
+
if (this.apiTransportMode === 'legacy') {
|
|
266
|
+
await this.setDatapointLegacy(dsn, propertyName, value);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await this.setDatapointModern(dsn, propertyName, value);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (!shouldSwitchToLegacyApi(error)) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
this.switchToLegacyTransport(`Modern Salus datapoint write failed for ${dsn}/${propertyName}: ${asErrorMessage(error)}`);
|
|
278
|
+
await this.setDatapointLegacy(dsn, propertyName, value);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async setDatapointModern(dsn, propertyName, value) {
|
|
158
282
|
const writeCacheKey = `${dsn}:${propertyName}`;
|
|
159
283
|
const preferredAttempt = this.preferredWriteAttemptByKey.get(writeCacheKey) ?? null;
|
|
160
284
|
const attempts = prioritizeByDescription(this.buildWriteAttempts(dsn, propertyName, value), preferredAttempt);
|
|
@@ -199,6 +323,101 @@ export class SalusCloudClient {
|
|
|
199
323
|
}
|
|
200
324
|
throw new Error(`Failed to write property ${propertyName} on ${dsn}. Attempts: ${failures.join(' | ')}`);
|
|
201
325
|
}
|
|
326
|
+
async setDatapointLegacy(dsn, propertyName, value) {
|
|
327
|
+
const encodedDsn = encodeURIComponent(dsn);
|
|
328
|
+
const encodedPropertyName = encodeURIComponent(propertyName);
|
|
329
|
+
const attempts = [
|
|
330
|
+
{
|
|
331
|
+
method: 'POST',
|
|
332
|
+
path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
|
|
333
|
+
body: {
|
|
334
|
+
datapoint: {
|
|
335
|
+
value,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {datapoint:{value}}',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
method: 'POST',
|
|
342
|
+
path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints`,
|
|
343
|
+
body: {
|
|
344
|
+
datapoint: {
|
|
345
|
+
value,
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints {datapoint:{value}}',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
method: 'PUT',
|
|
352
|
+
path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
|
|
353
|
+
body: {
|
|
354
|
+
datapoint: {
|
|
355
|
+
value,
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
description: 'PUT /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {datapoint:{value}}',
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
method: 'POST',
|
|
362
|
+
path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
|
|
363
|
+
body: {
|
|
364
|
+
value,
|
|
365
|
+
},
|
|
366
|
+
description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {value}',
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
const failures = [];
|
|
370
|
+
let fatalError;
|
|
371
|
+
for (const attempt of attempts) {
|
|
372
|
+
try {
|
|
373
|
+
await this.requestLegacyJson(attempt.path, {
|
|
374
|
+
method: attempt.method,
|
|
375
|
+
body: attempt.body,
|
|
376
|
+
auth: true,
|
|
377
|
+
expectedStatuses: DEFAULT_EXPECTED_STATUSES,
|
|
378
|
+
});
|
|
379
|
+
this.updateCachedProperty(dsn, propertyName, value);
|
|
380
|
+
if (this.verboseLogging) {
|
|
381
|
+
this.log.debug(`Legacy write succeeded via ${attempt.description}`);
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const failureMessage = error instanceof HttpStatusError
|
|
387
|
+
? `${attempt.description} -> HTTP ${error.status}`
|
|
388
|
+
: `${attempt.description} -> ${asErrorMessage(error)}`;
|
|
389
|
+
failures.push(failureMessage);
|
|
390
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_WRITE_SHAPE_FALLBACK.has(error.status)) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
fatalError = error;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (fatalError) {
|
|
401
|
+
throw new Error(`Failed to write property ${propertyName} on ${dsn} via legacy API. Fatal error: ${asErrorMessage(fatalError)}. Attempts: ${failures.join(' | ')}`);
|
|
402
|
+
}
|
|
403
|
+
throw new Error(`Failed to write property ${propertyName} on ${dsn} via legacy API. Attempts: ${failures.join(' | ')}`);
|
|
404
|
+
}
|
|
405
|
+
switchToLegacyTransport(reason) {
|
|
406
|
+
if (this.apiTransportMode === 'legacy') {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.apiTransportMode = 'legacy';
|
|
410
|
+
this.activeServiceApiBaseUrl = null;
|
|
411
|
+
this.authorizationHeaderProfile = 'accessBearer';
|
|
412
|
+
this.session = null;
|
|
413
|
+
this.authRequestInFlight = null;
|
|
414
|
+
this.hasWarnedAboutAuthCompanyCode = false;
|
|
415
|
+
if (!this.hasWarnedAboutLegacyFallback) {
|
|
416
|
+
this.hasWarnedAboutLegacyFallback = true;
|
|
417
|
+
this.log.warn(`Switching to legacy Salus cloud compatibility mode (${reason})`);
|
|
418
|
+
this.log.warn('Legacy mode uses /users/sign_in.json and /apiv1 endpoints for tenant compatibility.');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
202
421
|
rememberPreferredWriteAttempt(cacheKey, description) {
|
|
203
422
|
this.preferredWriteAttemptByKey.delete(cacheKey);
|
|
204
423
|
this.preferredWriteAttemptByKey.set(cacheKey, description);
|
|
@@ -491,6 +710,37 @@ export class SalusCloudClient {
|
|
|
491
710
|
},
|
|
492
711
|
});
|
|
493
712
|
}
|
|
713
|
+
refreshCompanyCodeCandidates() {
|
|
714
|
+
const candidates = buildCompanyCodeCandidates(this.configuredCompanyCode, this.session?.companyCode);
|
|
715
|
+
this.companyCodeCandidates = candidates;
|
|
716
|
+
if (this.activeCompanyCode && candidates.includes(this.activeCompanyCode)) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.activeCompanyCode = candidates[0] ?? null;
|
|
720
|
+
}
|
|
721
|
+
rotateCompanyCodeCandidate(attemptedCompanyCodes, preferredCode) {
|
|
722
|
+
if (preferredCode) {
|
|
723
|
+
const normalizedPreferred = normalizeNonEmptyString(preferredCode);
|
|
724
|
+
if (normalizedPreferred) {
|
|
725
|
+
const preferredKey = companyCodeCandidateKey(normalizedPreferred);
|
|
726
|
+
if (!attemptedCompanyCodes.has(preferredKey)) {
|
|
727
|
+
attemptedCompanyCodes.add(preferredKey);
|
|
728
|
+
this.activeCompanyCode = normalizedPreferred;
|
|
729
|
+
return normalizedPreferred;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
for (const candidate of this.companyCodeCandidates) {
|
|
734
|
+
const key = companyCodeCandidateKey(candidate);
|
|
735
|
+
if (attemptedCompanyCodes.has(key)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
attemptedCompanyCodes.add(key);
|
|
739
|
+
this.activeCompanyCode = candidate;
|
|
740
|
+
return candidate;
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
494
744
|
async ensureLoggedIn() {
|
|
495
745
|
if (this.session && Date.now() + SESSION_REFRESH_SAFETY_MS < this.session.expiresAtEpochMs) {
|
|
496
746
|
return;
|
|
@@ -513,6 +763,7 @@ export class SalusCloudClient {
|
|
|
513
763
|
this.authRequestInFlight = (async () => {
|
|
514
764
|
this.session = null;
|
|
515
765
|
this.session = await this.authenticateWithPassword();
|
|
766
|
+
this.refreshCompanyCodeCandidates();
|
|
516
767
|
this.log.info('Authenticated with Salus cloud');
|
|
517
768
|
})();
|
|
518
769
|
try {
|
|
@@ -531,11 +782,13 @@ export class SalusCloudClient {
|
|
|
531
782
|
if (!refreshToken) {
|
|
532
783
|
this.session = null;
|
|
533
784
|
this.session = await this.authenticateWithPassword();
|
|
785
|
+
this.refreshCompanyCodeCandidates();
|
|
534
786
|
this.log.info('Authenticated with Salus cloud');
|
|
535
787
|
return;
|
|
536
788
|
}
|
|
537
789
|
try {
|
|
538
790
|
this.session = await this.authenticateWithRefreshToken(refreshToken);
|
|
791
|
+
this.refreshCompanyCodeCandidates();
|
|
539
792
|
}
|
|
540
793
|
catch (error) {
|
|
541
794
|
this.session = null;
|
|
@@ -552,6 +805,116 @@ export class SalusCloudClient {
|
|
|
552
805
|
this.authRequestInFlight = null;
|
|
553
806
|
}
|
|
554
807
|
}
|
|
808
|
+
async ensureLegacyLoggedIn() {
|
|
809
|
+
if (this.legacySession && Date.now() + LEGACY_SESSION_REFRESH_SAFETY_MS < this.legacySession.expiresAtEpochMs) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
await this.loginLegacy();
|
|
813
|
+
}
|
|
814
|
+
async loginLegacy() {
|
|
815
|
+
if (this.legacyAuthRequestInFlight) {
|
|
816
|
+
return this.legacyAuthRequestInFlight;
|
|
817
|
+
}
|
|
818
|
+
this.legacyAuthRequestInFlight = (async () => {
|
|
819
|
+
this.legacySession = await this.authenticateLegacyWithPassword();
|
|
820
|
+
this.log.info('Authenticated with Salus cloud (legacy API compatibility mode)');
|
|
821
|
+
})();
|
|
822
|
+
try {
|
|
823
|
+
await this.legacyAuthRequestInFlight;
|
|
824
|
+
}
|
|
825
|
+
finally {
|
|
826
|
+
this.legacyAuthRequestInFlight = null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async authenticateLegacyWithPassword() {
|
|
830
|
+
const email = this.config.email?.trim();
|
|
831
|
+
const password = this.config.password;
|
|
832
|
+
if (!email || !password) {
|
|
833
|
+
throw new Error('Salus credentials are missing. Set email and password in plugin config.');
|
|
834
|
+
}
|
|
835
|
+
const totalAttempts = this.maxRetries + 1;
|
|
836
|
+
let lastError = new Error('Legacy Salus login did not return a session');
|
|
837
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
838
|
+
const orderedBaseUrls = this.getOrderedLegacyApiBaseUrls();
|
|
839
|
+
let sawRetriableFailure = false;
|
|
840
|
+
let sawDefinitiveFailure = false;
|
|
841
|
+
let definitiveError;
|
|
842
|
+
for (const baseUrl of orderedBaseUrls) {
|
|
843
|
+
try {
|
|
844
|
+
const response = await this.fetchWithTimeout(buildLegacyUrl(baseUrl, '/users/sign_in.json', true), {
|
|
845
|
+
method: 'POST',
|
|
846
|
+
headers: {
|
|
847
|
+
Accept: 'application/json',
|
|
848
|
+
'Content-Type': 'application/json',
|
|
849
|
+
'Accept-Language': ACCEPT_LANGUAGE,
|
|
850
|
+
'User-Agent': 'homebridge-salus-cloud/2026',
|
|
851
|
+
},
|
|
852
|
+
body: JSON.stringify({
|
|
853
|
+
user: {
|
|
854
|
+
email,
|
|
855
|
+
password,
|
|
856
|
+
},
|
|
857
|
+
}),
|
|
858
|
+
});
|
|
859
|
+
if (!response.ok) {
|
|
860
|
+
const responseText = await safeReadText(response);
|
|
861
|
+
const statusError = new HttpStatusError(responseText
|
|
862
|
+
? `Legacy Salus login failed at ${baseUrl} (HTTP ${response.status}) :: ${responseText}`
|
|
863
|
+
: `Legacy Salus login failed at ${baseUrl} (HTTP ${response.status})`, response.status, responseText);
|
|
864
|
+
if (isRetriableStatus(response.status)) {
|
|
865
|
+
lastError = statusError;
|
|
866
|
+
sawRetriableFailure = true;
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
sawDefinitiveFailure = true;
|
|
870
|
+
if (!definitiveError) {
|
|
871
|
+
definitiveError = statusError;
|
|
872
|
+
}
|
|
873
|
+
lastError = statusError;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const payload = await parseResponseBody(response);
|
|
877
|
+
const session = parseLegacyTokens(payload);
|
|
878
|
+
if (!session) {
|
|
879
|
+
const parseError = new Error(`Legacy Salus login succeeded at ${baseUrl}, but access token was missing in response.`);
|
|
880
|
+
sawDefinitiveFailure = true;
|
|
881
|
+
if (!definitiveError) {
|
|
882
|
+
definitiveError = parseError;
|
|
883
|
+
}
|
|
884
|
+
lastError = parseError;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
this.activeLegacyApiBaseUrl = baseUrl;
|
|
888
|
+
return session;
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
if (!this.allowInsecureTls && isTlsCertificateError(error)) {
|
|
892
|
+
const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
|
|
893
|
+
throw new Error(message);
|
|
894
|
+
}
|
|
895
|
+
if (isRetriableFailure(error)) {
|
|
896
|
+
sawRetriableFailure = true;
|
|
897
|
+
lastError = error;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
sawDefinitiveFailure = true;
|
|
901
|
+
if (!definitiveError) {
|
|
902
|
+
definitiveError = error;
|
|
903
|
+
}
|
|
904
|
+
lastError = error;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (sawDefinitiveFailure) {
|
|
908
|
+
throw definitiveError ?? lastError;
|
|
909
|
+
}
|
|
910
|
+
if (attempt < totalAttempts && sawRetriableFailure) {
|
|
911
|
+
await this.retryDelay(attempt, asErrorMessage(lastError));
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
throw new Error(`Legacy Salus login failed after retries: ${asErrorMessage(lastError)}`);
|
|
917
|
+
}
|
|
555
918
|
async authenticateWithPassword() {
|
|
556
919
|
const email = this.config.email?.trim();
|
|
557
920
|
const password = this.config.password;
|
|
@@ -647,6 +1010,25 @@ export class SalusCloudClient {
|
|
|
647
1010
|
}
|
|
648
1011
|
throw new Error(`No valid path candidates for request: ${paths.join(', ')}`);
|
|
649
1012
|
}
|
|
1013
|
+
async requestLegacyJsonWithPathFallback(paths, options) {
|
|
1014
|
+
let lastPathError;
|
|
1015
|
+
for (const path of paths) {
|
|
1016
|
+
try {
|
|
1017
|
+
return await this.requestLegacyJson(path, options);
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
1021
|
+
lastPathError = error;
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (lastPathError) {
|
|
1028
|
+
throw lastPathError;
|
|
1029
|
+
}
|
|
1030
|
+
throw new Error(`No valid legacy path candidates for request: ${paths.join(', ')}`);
|
|
1031
|
+
}
|
|
650
1032
|
async requestServiceJson(path, options) {
|
|
651
1033
|
const authRequired = options.auth ?? true;
|
|
652
1034
|
if (authRequired) {
|
|
@@ -655,7 +1037,8 @@ export class SalusCloudClient {
|
|
|
655
1037
|
const expectedStatuses = options.expectedStatuses ?? DEFAULT_EXPECTED_STATUSES;
|
|
656
1038
|
const totalAttempts = this.maxRetries + 1;
|
|
657
1039
|
let hasRefreshedSessionAfter401 = false;
|
|
658
|
-
|
|
1040
|
+
let attemptedAuthProfiles = new Set([this.authorizationHeaderProfile]);
|
|
1041
|
+
const attemptedCompanyCodes = new Set([companyCodeCandidateKey(this.activeCompanyCode)]);
|
|
659
1042
|
let lastError = new Error(`No Salus cloud response received for ${options.method} ${path}`);
|
|
660
1043
|
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
661
1044
|
const orderedBaseUrls = this.getOrderedServiceApiBaseUrls();
|
|
@@ -677,31 +1060,49 @@ export class SalusCloudClient {
|
|
|
677
1060
|
headers,
|
|
678
1061
|
body,
|
|
679
1062
|
});
|
|
680
|
-
if (response.status === 401 && authRequired && (options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
|
|
681
|
-
hasRefreshedSessionAfter401 = true;
|
|
682
|
-
this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Refreshing session token and retrying.`);
|
|
683
|
-
await this.refreshSession();
|
|
684
|
-
lastError = new HttpStatusError('Unauthorized', 401, '');
|
|
685
|
-
sawRetriableFailure = true;
|
|
686
|
-
lastRetriableError = lastError;
|
|
687
|
-
baseIndex -= 1;
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
1063
|
if (response.status === 401 && authRequired) {
|
|
1064
|
+
const responseText = await safeReadText(response);
|
|
1065
|
+
const responseCode = extractServiceResponseCode(responseText);
|
|
1066
|
+
const hintedCompanyCode = extractCompanyCodeFromServiceAuthError(responseText);
|
|
1067
|
+
if (responseCode === '900008' && !this.hasWarnedAboutAuthCompanyCode) {
|
|
1068
|
+
this.hasWarnedAboutAuthCompanyCode = true;
|
|
1069
|
+
this.log.warn('Salus cloud returned response_code=900008 (Not authorized). This often indicates tenant/company authorization context mismatch.');
|
|
1070
|
+
}
|
|
1071
|
+
if ((options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
|
|
1072
|
+
hasRefreshedSessionAfter401 = true;
|
|
1073
|
+
this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Refreshing session token and retrying.`);
|
|
1074
|
+
await this.refreshSession();
|
|
1075
|
+
lastError = new HttpStatusError('Unauthorized', 401, responseText);
|
|
1076
|
+
sawRetriableFailure = true;
|
|
1077
|
+
lastRetriableError = lastError;
|
|
1078
|
+
baseIndex -= 1;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
691
1081
|
const rotatedTo = rotateAuthorizationHeaderProfile(this.authorizationHeaderProfile);
|
|
692
1082
|
if (rotatedTo !== this.authorizationHeaderProfile && !attemptedAuthProfiles.has(rotatedTo)) {
|
|
693
1083
|
this.authorizationHeaderProfile = rotatedTo;
|
|
694
1084
|
attemptedAuthProfiles.add(rotatedTo);
|
|
695
1085
|
this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying with alternate auth header profile: ${rotatedTo}.`);
|
|
696
|
-
lastError = new HttpStatusError('Unauthorized', 401,
|
|
1086
|
+
lastError = new HttpStatusError('Unauthorized', 401, responseText);
|
|
1087
|
+
sawRetriableFailure = true;
|
|
1088
|
+
lastRetriableError = lastError;
|
|
1089
|
+
baseIndex -= 1;
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const previousCompanyCode = this.activeCompanyCode;
|
|
1093
|
+
const rotatedCompanyCode = this.rotateCompanyCodeCandidate(attemptedCompanyCodes, hintedCompanyCode);
|
|
1094
|
+
if (companyCodeCandidateKey(rotatedCompanyCode) !== companyCodeCandidateKey(previousCompanyCode)) {
|
|
1095
|
+
this.authorizationHeaderProfile = 'accessBearer';
|
|
1096
|
+
attemptedAuthProfiles = new Set([this.authorizationHeaderProfile]);
|
|
1097
|
+
this.log.warn(rotatedCompanyCode
|
|
1098
|
+
? `Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying with alternate company code header: ${rotatedCompanyCode}.`
|
|
1099
|
+
: `Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying without company code header.`);
|
|
1100
|
+
lastError = new HttpStatusError('Unauthorized', 401, responseText);
|
|
697
1101
|
sawRetriableFailure = true;
|
|
698
1102
|
lastRetriableError = lastError;
|
|
699
1103
|
baseIndex -= 1;
|
|
700
1104
|
continue;
|
|
701
1105
|
}
|
|
702
|
-
}
|
|
703
|
-
if (response.status === 401 && authRequired) {
|
|
704
|
-
const responseText = await safeReadText(response);
|
|
705
1106
|
const unauthorizedError = new HttpStatusError(responseText
|
|
706
1107
|
? `HTTP 401 Unauthorized on ${options.method} ${path} via ${baseUrl} :: ${responseText}`
|
|
707
1108
|
: `HTTP 401 Unauthorized on ${options.method} ${path} via ${baseUrl}`, 401, responseText);
|
|
@@ -760,6 +1161,9 @@ export class SalusCloudClient {
|
|
|
760
1161
|
lastError = error;
|
|
761
1162
|
sawRetriableFailure = true;
|
|
762
1163
|
lastRetriableError = error;
|
|
1164
|
+
if (this.verboseLogging) {
|
|
1165
|
+
this.log.debug(`Retriable network error for ${options.method} ${path} via ${baseUrl}: ${asErrorMessage(error)}`);
|
|
1166
|
+
}
|
|
763
1167
|
continue;
|
|
764
1168
|
}
|
|
765
1169
|
throw error;
|
|
@@ -778,6 +1182,125 @@ export class SalusCloudClient {
|
|
|
778
1182
|
}
|
|
779
1183
|
throw new Error(`Unexpected request state for ${options.method} ${path}`);
|
|
780
1184
|
}
|
|
1185
|
+
async requestLegacyJson(path, options) {
|
|
1186
|
+
const authRequired = options.auth ?? true;
|
|
1187
|
+
if (authRequired) {
|
|
1188
|
+
await this.ensureLegacyLoggedIn();
|
|
1189
|
+
}
|
|
1190
|
+
const expectedStatuses = options.expectedStatuses ?? DEFAULT_EXPECTED_STATUSES;
|
|
1191
|
+
const totalAttempts = this.maxRetries + 1;
|
|
1192
|
+
let hasRefreshedSessionAfter401 = false;
|
|
1193
|
+
let lastError = new Error(`No legacy Salus cloud response received for ${options.method} ${path}`);
|
|
1194
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
1195
|
+
const orderedBaseUrls = this.getOrderedLegacyApiBaseUrls();
|
|
1196
|
+
let sawRetriableFailure = false;
|
|
1197
|
+
let sawDefinitiveFailure = false;
|
|
1198
|
+
let definitiveError;
|
|
1199
|
+
let lastRetriableError;
|
|
1200
|
+
for (const baseUrl of orderedBaseUrls) {
|
|
1201
|
+
try {
|
|
1202
|
+
const headers = this.buildLegacyHeaders(authRequired);
|
|
1203
|
+
const body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
1204
|
+
if (body !== undefined) {
|
|
1205
|
+
headers['Content-Type'] = 'application/json';
|
|
1206
|
+
}
|
|
1207
|
+
const response = await this.fetchWithTimeout(buildLegacyUrl(baseUrl, path, options.method === 'GET'), {
|
|
1208
|
+
method: options.method,
|
|
1209
|
+
headers,
|
|
1210
|
+
body,
|
|
1211
|
+
});
|
|
1212
|
+
if (response.status === 401 && authRequired) {
|
|
1213
|
+
const responseText = await safeReadText(response);
|
|
1214
|
+
if ((options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
|
|
1215
|
+
hasRefreshedSessionAfter401 = true;
|
|
1216
|
+
this.log.warn(`Legacy Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Re-authenticating and retrying.`);
|
|
1217
|
+
this.legacySession = null;
|
|
1218
|
+
await this.loginLegacy();
|
|
1219
|
+
lastError = new HttpStatusError('Unauthorized', 401, responseText);
|
|
1220
|
+
sawRetriableFailure = true;
|
|
1221
|
+
lastRetriableError = lastError;
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
const unauthorizedError = new HttpStatusError(responseText
|
|
1225
|
+
? `HTTP 401 Unauthorized on legacy ${options.method} ${path} via ${baseUrl} :: ${responseText}`
|
|
1226
|
+
: `HTTP 401 Unauthorized on legacy ${options.method} ${path} via ${baseUrl}`, 401, responseText);
|
|
1227
|
+
lastError = unauthorizedError;
|
|
1228
|
+
sawRetriableFailure = true;
|
|
1229
|
+
lastRetriableError = unauthorizedError;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (!expectedStatuses.includes(response.status)) {
|
|
1233
|
+
const responseText = await safeReadText(response);
|
|
1234
|
+
const statusError = new HttpStatusError(responseText
|
|
1235
|
+
? `Legacy API HTTP ${response.status} ${response.statusText} on ${options.method} ${path} :: ${responseText}`
|
|
1236
|
+
: `Legacy API HTTP ${response.status} ${response.statusText} on ${options.method} ${path}`, response.status, responseText);
|
|
1237
|
+
if (STATUS_ALLOW_PATH_FALLBACK.has(response.status)) {
|
|
1238
|
+
lastError = statusError;
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (isRetriableStatus(response.status)) {
|
|
1242
|
+
lastError = statusError;
|
|
1243
|
+
sawRetriableFailure = true;
|
|
1244
|
+
lastRetriableError = statusError;
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
lastError = statusError;
|
|
1248
|
+
sawDefinitiveFailure = true;
|
|
1249
|
+
if (!definitiveError) {
|
|
1250
|
+
definitiveError = statusError;
|
|
1251
|
+
}
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
this.activeLegacyApiBaseUrl = baseUrl;
|
|
1255
|
+
if (response.status === 204) {
|
|
1256
|
+
return undefined;
|
|
1257
|
+
}
|
|
1258
|
+
return await parseResponseBody(response);
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
if (!this.allowInsecureTls && isTlsCertificateError(error)) {
|
|
1262
|
+
const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
|
|
1263
|
+
throw new Error(message);
|
|
1264
|
+
}
|
|
1265
|
+
if (error instanceof HttpStatusError) {
|
|
1266
|
+
lastError = error;
|
|
1267
|
+
if (isRetriableStatus(error.status)) {
|
|
1268
|
+
sawRetriableFailure = true;
|
|
1269
|
+
lastRetriableError = error;
|
|
1270
|
+
}
|
|
1271
|
+
else if (!STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
1272
|
+
sawDefinitiveFailure = true;
|
|
1273
|
+
if (!definitiveError) {
|
|
1274
|
+
definitiveError = error;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
if (isRetriableError(error)) {
|
|
1280
|
+
lastError = error;
|
|
1281
|
+
sawRetriableFailure = true;
|
|
1282
|
+
lastRetriableError = error;
|
|
1283
|
+
if (this.verboseLogging) {
|
|
1284
|
+
this.log.debug(`Retriable legacy network error for ${options.method} ${path} via ${baseUrl}: ${asErrorMessage(error)}`);
|
|
1285
|
+
}
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
throw error;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (sawDefinitiveFailure) {
|
|
1292
|
+
throw definitiveError ?? lastError;
|
|
1293
|
+
}
|
|
1294
|
+
if (attempt < totalAttempts && sawRetriableFailure) {
|
|
1295
|
+
await this.retryDelay(attempt, asErrorMessage(lastRetriableError ?? lastError));
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (lastError) {
|
|
1299
|
+
throw lastError;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
throw new Error(`Unexpected legacy request state for ${options.method} ${path}`);
|
|
1303
|
+
}
|
|
781
1304
|
buildServiceHeaders(authRequired) {
|
|
782
1305
|
const headers = {
|
|
783
1306
|
Accept: 'application/json',
|
|
@@ -790,15 +1313,32 @@ export class SalusCloudClient {
|
|
|
790
1313
|
if (!this.session) {
|
|
791
1314
|
throw new Error('Missing Salus cloud session while building authenticated request.');
|
|
792
1315
|
}
|
|
1316
|
+
const authToken = tokenForAuthorizationProfile(this.authorizationHeaderProfile, this.session.accessToken, this.session.idToken);
|
|
793
1317
|
headers.Authorization = formatAuthorizationHeader(this.authorizationHeaderProfile, this.session.accessToken, this.session.idToken);
|
|
794
1318
|
headers['x-access-token'] = this.session.accessToken;
|
|
795
|
-
headers['x-auth-token'] =
|
|
796
|
-
const companyCode = this.
|
|
1319
|
+
headers['x-auth-token'] = authToken;
|
|
1320
|
+
const companyCode = this.activeCompanyCode;
|
|
797
1321
|
if (companyCode) {
|
|
798
1322
|
headers['x-company-code'] = companyCode;
|
|
799
1323
|
}
|
|
800
1324
|
return headers;
|
|
801
1325
|
}
|
|
1326
|
+
buildLegacyHeaders(authRequired) {
|
|
1327
|
+
const headers = {
|
|
1328
|
+
Accept: 'application/json',
|
|
1329
|
+
'Accept-Language': ACCEPT_LANGUAGE,
|
|
1330
|
+
'User-Agent': 'homebridge-salus-cloud/2026',
|
|
1331
|
+
};
|
|
1332
|
+
if (!authRequired) {
|
|
1333
|
+
return headers;
|
|
1334
|
+
}
|
|
1335
|
+
if (!this.legacySession) {
|
|
1336
|
+
throw new Error('Missing Salus legacy session while building authenticated request.');
|
|
1337
|
+
}
|
|
1338
|
+
const tokenType = normalizeNonEmptyString(this.legacySession.tokenType) ?? 'Bearer';
|
|
1339
|
+
headers.Authorization = `${tokenType} ${this.legacySession.accessToken}`;
|
|
1340
|
+
return headers;
|
|
1341
|
+
}
|
|
802
1342
|
getOrderedServiceApiBaseUrls() {
|
|
803
1343
|
const dedupe = new Set();
|
|
804
1344
|
const ordered = [];
|
|
@@ -815,6 +1355,22 @@ export class SalusCloudClient {
|
|
|
815
1355
|
}
|
|
816
1356
|
return ordered;
|
|
817
1357
|
}
|
|
1358
|
+
getOrderedLegacyApiBaseUrls() {
|
|
1359
|
+
const dedupe = new Set();
|
|
1360
|
+
const ordered = [];
|
|
1361
|
+
if (this.activeLegacyApiBaseUrl) {
|
|
1362
|
+
dedupe.add(this.activeLegacyApiBaseUrl);
|
|
1363
|
+
ordered.push(this.activeLegacyApiBaseUrl);
|
|
1364
|
+
}
|
|
1365
|
+
for (const candidate of this.legacyApiBaseCandidates) {
|
|
1366
|
+
if (dedupe.has(candidate)) {
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
dedupe.add(candidate);
|
|
1370
|
+
ordered.push(candidate);
|
|
1371
|
+
}
|
|
1372
|
+
return ordered;
|
|
1373
|
+
}
|
|
818
1374
|
async retryDelay(attempt, reason) {
|
|
819
1375
|
const baseDelay = Math.min(MAX_RETRY_DELAY_MS, this.retryBaseDelayMs * (2 ** (attempt - 1)));
|
|
820
1376
|
const jitter = 0.85 + (Math.random() * 0.3);
|
|
@@ -886,6 +1442,57 @@ function buildServiceApiBaseCandidates(region, overrideHost, versionPreference)
|
|
|
886
1442
|
];
|
|
887
1443
|
return dedupeStringArray(hosts.flatMap((host) => buildApiVersionCandidates(host, versionPreference)));
|
|
888
1444
|
}
|
|
1445
|
+
function buildLegacyApiBaseCandidates(region, overrideHost) {
|
|
1446
|
+
const normalizedOverride = normalizeNonEmptyString(overrideHost);
|
|
1447
|
+
if (normalizedOverride) {
|
|
1448
|
+
const overrideCandidates = deriveLegacyHostCandidatesFromOverride(normalizedOverride);
|
|
1449
|
+
if (overrideCandidates.length > 0) {
|
|
1450
|
+
const withFallback = [
|
|
1451
|
+
...overrideCandidates,
|
|
1452
|
+
...(region === 'us'
|
|
1453
|
+
? [DEFAULT_US_LEGACY_API_HOST, FALLBACK_US_LEGACY_API_HOST, DEFAULT_EU_LEGACY_API_HOST, FALLBACK_EU_LEGACY_API_HOST]
|
|
1454
|
+
: [DEFAULT_EU_LEGACY_API_HOST, FALLBACK_EU_LEGACY_API_HOST, DEFAULT_US_LEGACY_API_HOST, FALLBACK_US_LEGACY_API_HOST]),
|
|
1455
|
+
];
|
|
1456
|
+
return dedupeStringArray(withFallback.map((value) => normalizeUrl(value)));
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const hosts = region === 'us'
|
|
1460
|
+
? [
|
|
1461
|
+
DEFAULT_US_LEGACY_API_HOST,
|
|
1462
|
+
FALLBACK_US_LEGACY_API_HOST,
|
|
1463
|
+
DEFAULT_EU_LEGACY_API_HOST,
|
|
1464
|
+
FALLBACK_EU_LEGACY_API_HOST,
|
|
1465
|
+
]
|
|
1466
|
+
: [
|
|
1467
|
+
DEFAULT_EU_LEGACY_API_HOST,
|
|
1468
|
+
FALLBACK_EU_LEGACY_API_HOST,
|
|
1469
|
+
DEFAULT_US_LEGACY_API_HOST,
|
|
1470
|
+
FALLBACK_US_LEGACY_API_HOST,
|
|
1471
|
+
];
|
|
1472
|
+
return dedupeStringArray(hosts.map((value) => normalizeUrl(value)));
|
|
1473
|
+
}
|
|
1474
|
+
function deriveLegacyHostCandidatesFromOverride(overrideHost) {
|
|
1475
|
+
const normalizedOverride = normalizeUrl(overrideHost);
|
|
1476
|
+
const candidates = new Set();
|
|
1477
|
+
try {
|
|
1478
|
+
const parsed = new URL(normalizedOverride);
|
|
1479
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1480
|
+
const protocol = parsed.protocol;
|
|
1481
|
+
candidates.add(`${protocol}//${hostname}`);
|
|
1482
|
+
if (hostname.startsWith('service-api.')) {
|
|
1483
|
+
candidates.add(`${protocol}//${hostname.replace(/^service-api\./, '')}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
catch {
|
|
1487
|
+
// Keep best-effort fallback.
|
|
1488
|
+
candidates.add(normalizedOverride.replace(/\/api\/v[12](?:\/.*)?$/i, '').replace(/\/+$/, ''));
|
|
1489
|
+
candidates.add(normalizedOverride
|
|
1490
|
+
.replace(/\/api\/v[12](?:\/.*)?$/i, '')
|
|
1491
|
+
.replace(/\/+$/, '')
|
|
1492
|
+
.replace(/:\/\/service-api\./i, '://'));
|
|
1493
|
+
}
|
|
1494
|
+
return [...candidates].filter((value) => value.trim() !== '');
|
|
1495
|
+
}
|
|
889
1496
|
function buildApiVersionCandidates(baseHost, preference) {
|
|
890
1497
|
const normalized = normalizeUrl(baseHost).replace(/\/+$/, '');
|
|
891
1498
|
const versions = preference === 'v1'
|
|
@@ -912,6 +1519,11 @@ function buildServiceUrl(baseUrl, path, addTimestamp) {
|
|
|
912
1519
|
const joined = `${baseUrl}${normalizedPath}`;
|
|
913
1520
|
return addTimestamp ? withTimestampQuery(joined) : joined;
|
|
914
1521
|
}
|
|
1522
|
+
function buildLegacyUrl(baseUrl, path, addTimestamp) {
|
|
1523
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
1524
|
+
const joined = `${baseUrl}${normalizedPath}`;
|
|
1525
|
+
return addTimestamp ? withTimestampQuery(joined) : joined;
|
|
1526
|
+
}
|
|
915
1527
|
function withTimestampQuery(url) {
|
|
916
1528
|
const separator = url.includes('?') ? '&' : '?';
|
|
917
1529
|
return `${url}${separator}timestamp=${Date.now()}`;
|
|
@@ -933,6 +1545,71 @@ function normalizeNonEmptyString(value) {
|
|
|
933
1545
|
}
|
|
934
1546
|
return trimmed;
|
|
935
1547
|
}
|
|
1548
|
+
function buildCompanyCodeCandidates(configuredCompanyCode, sessionCompanyCode) {
|
|
1549
|
+
const normalizedConfigured = normalizeNonEmptyString(configuredCompanyCode ?? undefined) ?? null;
|
|
1550
|
+
const normalizedSession = normalizeNonEmptyString(sessionCompanyCode);
|
|
1551
|
+
const fallbackCandidates = DEFAULT_COMPANY_CODE_FALLBACKS
|
|
1552
|
+
.map((value) => normalizeNonEmptyString(value))
|
|
1553
|
+
.filter((value) => Boolean(value));
|
|
1554
|
+
const initialCandidates = normalizedConfigured
|
|
1555
|
+
? [normalizedConfigured, normalizedSession ?? null, null]
|
|
1556
|
+
: [normalizedSession ?? null, null];
|
|
1557
|
+
const seen = new Set();
|
|
1558
|
+
const deduped = [];
|
|
1559
|
+
for (const candidate of [...initialCandidates, ...fallbackCandidates]) {
|
|
1560
|
+
const key = companyCodeCandidateKey(candidate);
|
|
1561
|
+
if (seen.has(key)) {
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
seen.add(key);
|
|
1565
|
+
deduped.push(candidate);
|
|
1566
|
+
}
|
|
1567
|
+
return deduped;
|
|
1568
|
+
}
|
|
1569
|
+
function companyCodeCandidateKey(candidate) {
|
|
1570
|
+
return candidate ?? NO_COMPANY_CODE_SENTINEL;
|
|
1571
|
+
}
|
|
1572
|
+
function tokenForAuthorizationProfile(profile, accessToken, idToken) {
|
|
1573
|
+
if (profile === 'idBearer' || profile === 'idRaw') {
|
|
1574
|
+
return idToken;
|
|
1575
|
+
}
|
|
1576
|
+
return accessToken;
|
|
1577
|
+
}
|
|
1578
|
+
function extractServiceResponseCode(responseText) {
|
|
1579
|
+
if (!responseText) {
|
|
1580
|
+
return undefined;
|
|
1581
|
+
}
|
|
1582
|
+
const parsed = parseJsonRecord(responseText);
|
|
1583
|
+
if (!parsed) {
|
|
1584
|
+
return undefined;
|
|
1585
|
+
}
|
|
1586
|
+
return asString(parsed.response_code)
|
|
1587
|
+
?? asString(parsed.code)
|
|
1588
|
+
?? asString(parsed.error_code);
|
|
1589
|
+
}
|
|
1590
|
+
function extractCompanyCodeFromServiceAuthError(responseText) {
|
|
1591
|
+
if (!responseText) {
|
|
1592
|
+
return undefined;
|
|
1593
|
+
}
|
|
1594
|
+
const parsed = parseJsonRecord(responseText);
|
|
1595
|
+
if (!parsed) {
|
|
1596
|
+
return undefined;
|
|
1597
|
+
}
|
|
1598
|
+
const direct = asRecord(parsed.data) ?? parsed;
|
|
1599
|
+
return extractCompanyCodeFromTokenClaims(direct);
|
|
1600
|
+
}
|
|
1601
|
+
function parseJsonRecord(value) {
|
|
1602
|
+
const trimmed = value.trim();
|
|
1603
|
+
if (!trimmed) {
|
|
1604
|
+
return undefined;
|
|
1605
|
+
}
|
|
1606
|
+
try {
|
|
1607
|
+
return asRecord(JSON.parse(trimmed));
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
return undefined;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
936
1613
|
function parseCognitoTokens(payload, refreshTokenFallback) {
|
|
937
1614
|
const record = asRecord(payload);
|
|
938
1615
|
if (!record) {
|
|
@@ -966,6 +1643,40 @@ function parseCognitoTokens(payload, refreshTokenFallback) {
|
|
|
966
1643
|
companyCode: extractCompanyCodeFromTokenClaims(decodedIdTokenClaims, decodedAccessTokenClaims),
|
|
967
1644
|
};
|
|
968
1645
|
}
|
|
1646
|
+
function parseLegacyTokens(payload) {
|
|
1647
|
+
const root = asRecord(payload);
|
|
1648
|
+
if (!root) {
|
|
1649
|
+
return undefined;
|
|
1650
|
+
}
|
|
1651
|
+
const valueRecord = asRecord(root.value);
|
|
1652
|
+
const candidateRecords = [
|
|
1653
|
+
root,
|
|
1654
|
+
valueRecord,
|
|
1655
|
+
asRecord(root.user),
|
|
1656
|
+
valueRecord ? asRecord(valueRecord.user) : undefined,
|
|
1657
|
+
].filter((value) => Boolean(value));
|
|
1658
|
+
for (const candidate of candidateRecords) {
|
|
1659
|
+
const accessToken = normalizeNonEmptyString(asString(candidate.access_token)
|
|
1660
|
+
?? asString(candidate.accessToken)
|
|
1661
|
+
?? asString(candidate.token)
|
|
1662
|
+
?? asString(candidate.auth_token)
|
|
1663
|
+
?? asString(candidate.bearer_token));
|
|
1664
|
+
if (!accessToken) {
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
const tokenType = normalizeNonEmptyString(asString(candidate.token_type) ?? asString(candidate.tokenType)) ?? 'Bearer';
|
|
1668
|
+
const expiresInRaw = parseNumberLike(candidate.expires_in ?? candidate.expiresIn ?? candidate.expired_in);
|
|
1669
|
+
const expiresInMs = Number.isFinite(expiresInRaw)
|
|
1670
|
+
? Math.max(60_000, Math.floor(Number(expiresInRaw) * 1_000))
|
|
1671
|
+
: LEGACY_DEFAULT_SESSION_TTL_MS;
|
|
1672
|
+
return {
|
|
1673
|
+
accessToken,
|
|
1674
|
+
tokenType,
|
|
1675
|
+
expiresAtEpochMs: Date.now() + expiresInMs,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
return undefined;
|
|
1679
|
+
}
|
|
969
1680
|
function formatAuthorizationHeader(profile, accessToken, idToken) {
|
|
970
1681
|
if (profile === 'idBearer') {
|
|
971
1682
|
return `Bearer ${idToken}`;
|
|
@@ -1061,7 +1772,7 @@ function parseDevices(payload) {
|
|
|
1061
1772
|
candidates.push(payload);
|
|
1062
1773
|
}
|
|
1063
1774
|
if (root) {
|
|
1064
|
-
const arrayKeys = ['devices', 'registered_nodes', 'nodes', 'results', 'data', 'list', 'device_list'];
|
|
1775
|
+
const arrayKeys = ['devices', 'registered_nodes', 'nodes', 'results', 'data', 'list', 'device_list', 'value'];
|
|
1065
1776
|
for (const key of arrayKeys) {
|
|
1066
1777
|
const arr = asArray(root[key]);
|
|
1067
1778
|
if (arr) {
|
|
@@ -1216,17 +1927,30 @@ function parseProperties(payload) {
|
|
|
1216
1927
|
const result = new Map();
|
|
1217
1928
|
const root = asRecord(payload);
|
|
1218
1929
|
const candidateArrays = [];
|
|
1930
|
+
const candidateSingles = [];
|
|
1219
1931
|
if (Array.isArray(payload)) {
|
|
1220
1932
|
candidateArrays.push(payload);
|
|
1221
1933
|
}
|
|
1222
1934
|
if (root) {
|
|
1223
|
-
const keys = ['properties', 'property', 'data', 'results', 'datapoints', 'list'];
|
|
1935
|
+
const keys = ['properties', 'property', 'data', 'results', 'datapoints', 'list', 'value'];
|
|
1224
1936
|
for (const key of keys) {
|
|
1225
1937
|
const maybeArray = asArray(root[key]);
|
|
1226
1938
|
if (maybeArray) {
|
|
1227
1939
|
candidateArrays.push(maybeArray);
|
|
1228
1940
|
}
|
|
1229
1941
|
}
|
|
1942
|
+
const valueRecord = asRecord(root.value);
|
|
1943
|
+
if (valueRecord) {
|
|
1944
|
+
candidateSingles.push(valueRecord);
|
|
1945
|
+
const nestedArrayKeys = ['properties', 'property', 'data', 'results', 'datapoints', 'list'];
|
|
1946
|
+
for (const key of nestedArrayKeys) {
|
|
1947
|
+
const nestedArray = asArray(valueRecord[key]);
|
|
1948
|
+
if (nestedArray) {
|
|
1949
|
+
candidateArrays.push(nestedArray);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
candidateSingles.push(root);
|
|
1230
1954
|
}
|
|
1231
1955
|
for (const entries of candidateArrays) {
|
|
1232
1956
|
for (const entry of entries) {
|
|
@@ -1236,8 +1960,18 @@ function parseProperties(payload) {
|
|
|
1236
1960
|
}
|
|
1237
1961
|
}
|
|
1238
1962
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1963
|
+
for (const candidate of candidateSingles) {
|
|
1964
|
+
const property = parsePropertyEntry(candidate);
|
|
1965
|
+
if (property) {
|
|
1966
|
+
result.set(property.name, property);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
for (const candidate of candidateSingles) {
|
|
1970
|
+
const record = asRecord(candidate);
|
|
1971
|
+
if (!record) {
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
parsePropertyObjectMap(record, result);
|
|
1241
1975
|
}
|
|
1242
1976
|
return result;
|
|
1243
1977
|
}
|
|
@@ -1460,6 +2194,22 @@ function isRetriableFailure(error) {
|
|
|
1460
2194
|
}
|
|
1461
2195
|
return isRetriableError(error);
|
|
1462
2196
|
}
|
|
2197
|
+
function shouldSwitchToLegacyApi(error) {
|
|
2198
|
+
if (!(error instanceof HttpStatusError)) {
|
|
2199
|
+
return false;
|
|
2200
|
+
}
|
|
2201
|
+
if (error.status === 401 || error.status === 403) {
|
|
2202
|
+
const responseCode = extractServiceResponseCode(error.responseBody);
|
|
2203
|
+
if (responseCode === '900008') {
|
|
2204
|
+
return true;
|
|
2205
|
+
}
|
|
2206
|
+
const body = error.responseBody.toLowerCase();
|
|
2207
|
+
if (body.includes('not authorized') || body.includes('unauthorized')) {
|
|
2208
|
+
return true;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
1463
2213
|
function isRetriableStatus(status) {
|
|
1464
2214
|
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
1465
2215
|
}
|