@webex/webex-core 3.11.0-webex-services-ready.1 → 3.12.0-auth-prejoin-fetch.1

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 (49) hide show
  1. package/README.md +5 -2
  2. package/dist/config.js +24 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/credentials-config.js +12 -0
  5. package/dist/credentials-config.js.map +1 -1
  6. package/dist/interceptors/redirect.js +1 -1
  7. package/dist/interceptors/redirect.js.map +1 -1
  8. package/dist/lib/batcher.js +23 -7
  9. package/dist/lib/batcher.js.map +1 -1
  10. package/dist/lib/credentials/credentials.js +48 -4
  11. package/dist/lib/credentials/credentials.js.map +1 -1
  12. package/dist/lib/credentials/token.js +1 -1
  13. package/dist/lib/domains.js +90 -0
  14. package/dist/lib/domains.js.map +1 -0
  15. package/dist/lib/services/service-catalog.js +6 -10
  16. package/dist/lib/services/service-catalog.js.map +1 -1
  17. package/dist/lib/services/services.js +244 -57
  18. package/dist/lib/services/services.js.map +1 -1
  19. package/dist/lib/services-v2/service-catalog.js +6 -12
  20. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  21. package/dist/lib/services-v2/services-v2.js +243 -52
  22. package/dist/lib/services-v2/services-v2.js.map +1 -1
  23. package/dist/plugins/logger.js +1 -1
  24. package/dist/webex-core.js +2 -2
  25. package/dist/webex-core.js.map +1 -1
  26. package/package.json +13 -13
  27. package/src/config.js +27 -0
  28. package/src/credentials-config.js +13 -0
  29. package/src/interceptors/redirect.js +4 -1
  30. package/src/lib/batcher.js +25 -10
  31. package/src/lib/credentials/credentials.js +50 -3
  32. package/src/lib/domains.ts +94 -0
  33. package/src/lib/services/service-catalog.js +6 -10
  34. package/src/lib/services/services.js +218 -46
  35. package/src/lib/services-v2/service-catalog.ts +6 -11
  36. package/src/lib/services-v2/services-v2.ts +218 -42
  37. package/test/fixtures/activation-email.ts +22 -0
  38. package/test/integration/spec/services/service-catalog.js +7 -6
  39. package/test/integration/spec/services/services.js +188 -128
  40. package/test/integration/spec/services-v2/services-v2.js +174 -114
  41. package/test/unit/spec/credentials/credentials.js +133 -2
  42. package/test/unit/spec/interceptors/auth.js +56 -0
  43. package/test/unit/spec/lib/batcher.js +56 -0
  44. package/test/unit/spec/services/service-catalog.js +93 -11
  45. package/test/unit/spec/services/services.js +591 -322
  46. package/test/unit/spec/services-v2/service-catalog.ts +93 -11
  47. package/test/unit/spec/services-v2/services-v2.ts +539 -214
  48. package/test/unit/spec/webex-core.js +0 -2
  49. package/test/unit/spec/webex-internal-core.js +0 -2
@@ -4,6 +4,7 @@ import AmpState from 'ampersand-state';
4
4
 
5
5
  import {union} from 'lodash';
6
6
  import ServiceUrl from './service-url';
7
+ import {matchAllowedDomain, normalizeAllowedDomains} from '../domains';
7
8
 
8
9
  /* eslint-disable no-underscore-dangle */
9
10
  /**
@@ -268,19 +269,14 @@ const ServiceCatalog = AmpState.extend({
268
269
  },
269
270
 
270
271
  /**
271
- * Finds an allowed domain that matches a specific url.
272
+ * Finds an allowed domain that matches a specific url. The url's hostname
273
+ * must be the allowed domain itself or a subdomain of it.
272
274
  *
273
275
  * @param {string} url - The url to match the allowed domains against.
274
276
  * @returns {string} - The matching allowed domain.
275
277
  */
276
278
  findAllowedDomain(url) {
277
- const urlObj = Url.parse(url);
278
-
279
- if (!urlObj.host) {
280
- return undefined;
281
- }
282
-
283
- return this.allowedDomains.find((allowedDomain) => urlObj.host.includes(allowedDomain));
279
+ return matchAllowedDomain(url, this.allowedDomains);
284
280
  },
285
281
 
286
282
  /**
@@ -367,7 +363,7 @@ const ServiceCatalog = AmpState.extend({
367
363
  * @returns {void}
368
364
  */
369
365
  setAllowedDomains(allowedDomains) {
370
- this.allowedDomains = [...allowedDomains];
366
+ this.allowedDomains = normalizeAllowedDomains(allowedDomains);
371
367
  },
372
368
 
373
369
  /**
@@ -376,7 +372,7 @@ const ServiceCatalog = AmpState.extend({
376
372
  * @returns {void}
377
373
  */
378
374
  addAllowedDomains(newAllowedDomains) {
379
- this.allowedDomains = union(this.allowedDomains, newAllowedDomains);
375
+ this.allowedDomains = union(this.allowedDomains, normalizeAllowedDomains(newAllowedDomains));
380
376
  },
381
377
 
382
378
  /**
@@ -1,4 +1,3 @@
1
- /* eslint-disable no-console */
2
1
  import sha256 from 'crypto-js/sha256';
3
2
 
4
3
  import {union, forEach} from 'lodash';
@@ -60,8 +59,10 @@ const Services = WebexPlugin.extend({
60
59
 
61
60
  session: {
62
61
  /**
63
- * Becomes `true` once service catalog initialization has completed.
64
- * Blocks `webex.ready` until services are initialized.
62
+ * Becomes `true` once the initial catalog collection has completed
63
+ * (successfully or otherwise) and any in-flight credentials refresh has
64
+ * settled. Blocks `webex.ready` so consumers can rely on `webex.ready`
65
+ * implying "catalogs populated AND credential state stable".
65
66
  * @instance
66
67
  * @memberof Services
67
68
  * @type {boolean}
@@ -258,6 +259,28 @@ const Services = WebexPlugin.extend({
258
259
 
259
260
  return !!hostCatalog[host]?.length;
260
261
  },
262
+
263
+ /**
264
+ * Checks if the current environment is an integration (INT) environment
265
+ * by examining the u2c discovery URL from webex config.
266
+ * INT environments use discovery URLs containing 'intb' (e.g., u2c-intb.ciscospark.com).
267
+ * @returns {boolean} True if INT environment, false otherwise
268
+ */
269
+ isIntegrationEnvironment() {
270
+ try {
271
+ const u2cUrl = this.webex?.config?.services?.discovery?.u2c || '';
272
+ const isInt = u2cUrl.includes('intb');
273
+
274
+ this.logger.info(`services: isIntegrationEnvironment: ${isInt}`);
275
+
276
+ return isInt;
277
+ } catch (error) {
278
+ this.logger.error('services: failed to determine integration environment', error);
279
+
280
+ return false;
281
+ }
282
+ },
283
+
261
284
  /**
262
285
  * Merge provided active cluster mappings into current state.
263
286
  * @param {Record<string,string>} activeServices
@@ -1080,9 +1103,16 @@ const Services = WebexPlugin.extend({
1080
1103
  requestObject.headers = {authorization: token};
1081
1104
  }
1082
1105
 
1083
- return this.webex.internal.newMetrics.callDiagnosticLatencies
1084
- .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time')
1085
- .then(({body}) => body);
1106
+ const sendRequest = () => this.request(requestObject);
1107
+
1108
+ const responsePromise = this.webex.internal.newMetrics
1109
+ ? this.webex.internal.newMetrics.callDiagnosticLatencies.measureLatency(
1110
+ sendRequest,
1111
+ 'internal.get.u2c.time'
1112
+ )
1113
+ : sendRequest();
1114
+
1115
+ return responsePromise.then(({body}) => body);
1086
1116
  },
1087
1117
 
1088
1118
  /**
@@ -1339,6 +1369,7 @@ const Services = WebexPlugin.extend({
1339
1369
 
1340
1370
  // Destructure the credentials plugin.
1341
1371
  const {credentials} = this.webex;
1372
+ const catalog = this._getCatalog();
1342
1373
 
1343
1374
  // Init a promise chain. Must be done as a Promise.resolve() to allow
1344
1375
  // credentials#getOrgId() to properly throw.
@@ -1351,12 +1382,18 @@ const Services = WebexPlugin.extend({
1351
1382
  .then(() => {
1352
1383
  // Validate if the token is authorized.
1353
1384
  if (credentials.canAuthorize) {
1354
- // Attempt to collect the postauth catalog.
1355
-
1356
- return this.updateServices().catch(() => {
1357
- this.initFailed = true;
1358
- this.logger.warn('services: cannot retrieve postauth catalog');
1359
- });
1385
+ // Attempt to collect the postauth catalog, then mark the catalog
1386
+ // ready. Setting `isReady` here - rather than only in the init
1387
+ // callers - means a slow postauth fetch that loses the gated-init
1388
+ // timeout race still marks the catalog ready once it completes.
1389
+ return this.updateServices()
1390
+ .then(() => {
1391
+ catalog.isReady = true;
1392
+ })
1393
+ .catch(() => {
1394
+ this.initFailed = true;
1395
+ this.logger.warn('services: cannot retrieve postauth catalog');
1396
+ });
1360
1397
  }
1361
1398
 
1362
1399
  // Return a resolved promise for consistent return value.
@@ -1365,6 +1402,51 @@ const Services = WebexPlugin.extend({
1365
1402
  );
1366
1403
  },
1367
1404
 
1405
+ /**
1406
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1407
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1408
+ * refresh is in flight when initial catalog collection settles, we must not
1409
+ * signal ready until the refresh has resolved - otherwise downstream
1410
+ * consumers may observe `canAuthorize`/token state that is about to change
1411
+ * under them.
1412
+ *
1413
+ * @private
1414
+ * @returns {Promise<void>}
1415
+ */
1416
+ async _finalizeReady() {
1417
+ const {credentials} = this.webex;
1418
+
1419
+ if (credentials && credentials.isRefreshing) {
1420
+ await new Promise((resolve) => {
1421
+ credentials.once('change:isRefreshing', resolve);
1422
+ });
1423
+ }
1424
+
1425
+ this.ready = true;
1426
+ },
1427
+
1428
+ /**
1429
+ * Build a promise that rejects once the catalog init timeout elapses. Race
1430
+ * this against catalog collection so a hung request never leaves
1431
+ * `services.ready` false forever - that would stall `webex.ready` and leave
1432
+ * consumers waiting on it indefinitely. Timeout is configurable via
1433
+ * `config.services.catalogInitTimeout` (defaults to 15s in config). Created
1434
+ * lazily so paths that skip catalog collection never schedule a stray timer.
1435
+ *
1436
+ * @private
1437
+ * @returns {Promise<never>}
1438
+ */
1439
+ _makeInitTimeout() {
1440
+ const initTimeoutMs = this.webex.config?.services?.catalogInitTimeout;
1441
+
1442
+ return new Promise((_, reject) => {
1443
+ setTimeout(
1444
+ () => reject(new Error(`services: init timed out after ${initTimeoutMs}ms`)),
1445
+ initTimeoutMs
1446
+ );
1447
+ });
1448
+ },
1449
+
1368
1450
  /**
1369
1451
  * Initializer
1370
1452
  *
@@ -1381,67 +1463,157 @@ const Services = WebexPlugin.extend({
1381
1463
  this.registries.set(this.webex, registry);
1382
1464
  this.states.set(this.webex, state);
1383
1465
 
1384
- // Listen for configuration changes once.
1466
+ // Listen for configuration changes once. The config is not populated on the
1467
+ // webex instance until the `change:config` event fires, so any decision that
1468
+ // depends on config values (such as the gated-vs-ungated init below) must be
1469
+ // made from within this handler rather than synchronously in `initialize()`.
1385
1470
  this.listenToOnce(this.webex, 'change:config', () => {
1386
1471
  this.initConfig();
1472
+
1473
+ // Feature flag: when enabled, `webex.ready` is blocked until the initial
1474
+ // catalog collection has settled AND any in-flight credentials refresh has
1475
+ // completed. When disabled (the default), preserves the pre-existing
1476
+ // behavior where `webex.ready` fires as soon as `webex.loaded` does and
1477
+ // the catalog is collected out-of-band.
1478
+ const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
1479
+
1480
+ if (waitForCatalogInit) {
1481
+ this._initializeCatalogsGated(catalog);
1482
+ } else {
1483
+ // Not gating - immediately mark ready so we do not block webex.ready.
1484
+ this.ready = true;
1485
+ this._initializeCatalogsUngated(catalog);
1486
+ }
1487
+ });
1488
+ },
1489
+
1490
+ /**
1491
+ * Original (pre-verified-ready) initialization path. Runs on `webex.ready`
1492
+ * and collects catalogs opportunistically without blocking anything.
1493
+ *
1494
+ * @private
1495
+ * @param {ServiceCatalog} catalog
1496
+ * @returns {void}
1497
+ */
1498
+ _initializeCatalogsUngated(catalog) {
1499
+ // wait for webex instance to be ready before attempting
1500
+ // to update the service catalogs
1501
+ // this can cause a race condition because credentials may
1502
+ // not be valid when services is initialized
1503
+ this.listenToOnce(this.webex, 'ready', async () => {
1504
+ const cachedCatalog = await this._loadCatalogFromCache();
1505
+ if (cachedCatalog) {
1506
+ catalog.isReady = true;
1507
+
1508
+ return; // skip initServiceCatalogs() on reload when cache exists
1509
+ }
1510
+ const {supertoken} = this.webex.credentials;
1511
+ // Validate if the supertoken exists.
1512
+ if (supertoken && supertoken.access_token) {
1513
+ // `initServiceCatalogs` marks the catalog ready internally once the
1514
+ // postauth catalog is collected.
1515
+ this.initServiceCatalogs().catch((error) => {
1516
+ this.initFailed = true;
1517
+ this.logger.error(
1518
+ `services: failed to init initial services when credentials available, ${error?.message}`
1519
+ );
1520
+ });
1521
+ } else {
1522
+ const {email} = this.webex.config;
1523
+
1524
+ if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
1525
+ this.logger.info(
1526
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
1527
+ );
1528
+
1529
+ return;
1530
+ }
1531
+
1532
+ this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1533
+ this.initFailed = true;
1534
+ this.logger.error(
1535
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1536
+ );
1537
+ });
1538
+ }
1387
1539
  });
1540
+ },
1388
1541
 
1389
- // Wait for storage to be loaded before attempting to update the service catalogs.
1390
- // We listen for 'loaded' instead of 'ready' because services.ready is a dependency
1391
- // of webex.ready - listening to 'ready' would cause a deadlock.
1542
+ /**
1543
+ * Verified-ready initialization path. Blocks `webex.ready` until the initial
1544
+ * catalog fetch has settled (or timed out) AND any in-flight credentials
1545
+ * refresh has completed. Also handles the fresh-login case where OAuth
1546
+ * completes after `loaded` fires.
1547
+ *
1548
+ * @private
1549
+ * @param {ServiceCatalog} catalog
1550
+ * @returns {void}
1551
+ */
1552
+ _initializeCatalogsGated(catalog) {
1553
+ // Wait for storage to be loaded before attempting to update the service
1554
+ // catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
1555
+ // now blocks `webex.ready` - listening to 'ready' would deadlock.
1392
1556
  this.listenToOnce(this.webex, 'loaded', async () => {
1393
1557
  const cachedCatalog = await this._loadCatalogFromCache();
1394
1558
  if (cachedCatalog) {
1395
1559
  catalog.isReady = true;
1560
+ await this._finalizeReady();
1396
1561
 
1397
1562
  return; // skip initServiceCatalogs() on reload when cache exists
1398
1563
  }
1399
1564
  const {supertoken} = this.webex.credentials;
1565
+
1400
1566
  // Validate if the supertoken exists.
1401
1567
  if (supertoken && supertoken.access_token) {
1402
- this.initServiceCatalogs()
1403
- .then(() => {
1404
- catalog.isReady = true;
1405
- })
1568
+ // `initServiceCatalogs` marks the catalog ready internally once the
1569
+ // postauth catalog is collected - even if it loses the timeout race
1570
+ // below, so a slow fetch still eventually flips `catalog.isReady`.
1571
+ Promise.race([this.initServiceCatalogs(), this._makeInitTimeout()])
1406
1572
  .catch((error) => {
1407
1573
  this.initFailed = true;
1408
1574
  this.logger.error(
1409
1575
  `services: failed to init initial services when credentials available, ${error?.message}`
1410
1576
  );
1411
1577
  })
1412
- .finally(() => {
1413
- this.ready = true;
1414
- this.trigger('services:initialized');
1415
- });
1578
+ .finally(() => this._finalizeReady());
1416
1579
  } else {
1417
1580
  const {email} = this.webex.config;
1418
1581
 
1419
- this.collectPreauthCatalog(email ? {email} : undefined)
1420
- .catch((error) => {
1421
- this.initFailed = true;
1422
- this.logger.error(
1423
- `services: failed to init initial services when no credentials available, ${error?.message}`
1424
- );
1425
- })
1426
- .finally(() => {
1427
- this.ready = true;
1428
- this.trigger('services:initialized');
1429
- });
1430
- // Listen for when credentials become available to fetch the full catalog.
1431
- // This handles fresh login where 'loaded' fires before OAuth completes.
1582
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1583
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1432
1584
  this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1433
1585
  if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1434
- this.initServiceCatalogs()
1435
- .then(() => {
1436
- catalog.isReady = true;
1437
- })
1438
- .catch((error) => {
1439
- this.logger.error(
1440
- `services: failed to init service catalogs after auth, ${error?.message}`
1441
- );
1442
- });
1586
+ // `initServiceCatalogs` marks the catalog ready internally.
1587
+ this.initServiceCatalogs().catch((error) => {
1588
+ this.logger.error(
1589
+ `services: failed to init service catalogs after auth, ${error?.message}`
1590
+ );
1591
+ });
1443
1592
  }
1444
1593
  });
1594
+
1595
+ if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
1596
+ // Skip the preauth catalog fetch (it will be collected manually
1597
+ // later), but still finalize `services.ready` so `webex.ready` is not
1598
+ // stalled while unauthenticated. No timeout is created here so there
1599
+ // is no stray timer or unhandled rejection.
1600
+ this.logger.info(
1601
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
1602
+ );
1603
+ this._finalizeReady();
1604
+ } else {
1605
+ Promise.race([
1606
+ this.collectPreauthCatalog(email ? {email} : undefined),
1607
+ this._makeInitTimeout(),
1608
+ ])
1609
+ .catch((error) => {
1610
+ this.initFailed = true;
1611
+ this.logger.error(
1612
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1613
+ );
1614
+ })
1615
+ .finally(() => this._finalizeReady());
1616
+ }
1445
1617
  }
1446
1618
  });
1447
1619
  },
@@ -3,6 +3,7 @@ import AmpState from 'ampersand-state';
3
3
  import {union} from 'lodash';
4
4
  import ServiceDetail from './service-detail';
5
5
  import {IServiceDetail, ServiceGroup} from './types';
6
+ import {matchAllowedDomain, normalizeAllowedDomains} from '../domains';
6
7
 
7
8
  /**
8
9
  * @class
@@ -210,20 +211,14 @@ const ServiceCatalog = AmpState.extend({
210
211
  },
211
212
 
212
213
  /**
213
- * Finds an allowed domain that matches a specific url.
214
+ * Finds an allowed domain that matches a specific url. The url's hostname
215
+ * must be the allowed domain itself or a subdomain of it.
214
216
  *
215
217
  * @param {string} url - The url to match the allowed domains against.
216
218
  * @returns {string} - The matching allowed domain.
217
219
  */
218
220
  findAllowedDomain(url: string): string {
219
- try {
220
- const urlObj = new URL(url);
221
-
222
- return this.allowedDomains.find((allowedDomain) => urlObj.host.includes(allowedDomain));
223
- } catch {
224
- // If the URL is invalid or can't be found, return undefined
225
- return undefined;
226
- }
221
+ return matchAllowedDomain(url, this.allowedDomains);
227
222
  },
228
223
 
229
224
  /**
@@ -282,7 +277,7 @@ const ServiceCatalog = AmpState.extend({
282
277
  * @returns {void}
283
278
  */
284
279
  setAllowedDomains(allowedDomains: Array<string>): void {
285
- this.allowedDomains = [...allowedDomains];
280
+ this.allowedDomains = normalizeAllowedDomains(allowedDomains);
286
281
  },
287
282
 
288
283
  /**
@@ -291,7 +286,7 @@ const ServiceCatalog = AmpState.extend({
291
286
  * @returns {void}
292
287
  */
293
288
  addAllowedDomains(newAllowedDomains: Array<string>): void {
294
- this.allowedDomains = union(this.allowedDomains, newAllowedDomains);
289
+ this.allowedDomains = union(this.allowedDomains, normalizeAllowedDomains(newAllowedDomains));
295
290
  },
296
291
 
297
292
  /**