@webex/webex-core 3.11.0 → 3.12.0-llmrefactor.2

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 (46) 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 +249 -35
  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 +253 -34
  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 +220 -17
  35. package/src/lib/services-v2/service-catalog.ts +6 -11
  36. package/src/lib/services-v2/services-v2.ts +224 -17
  37. package/test/fixtures/activation-email.ts +22 -0
  38. package/test/integration/spec/services/services.js +183 -112
  39. package/test/integration/spec/services-v2/services-v2.js +169 -98
  40. package/test/unit/spec/credentials/credentials.js +133 -2
  41. package/test/unit/spec/interceptors/auth.js +56 -0
  42. package/test/unit/spec/lib/batcher.js +56 -0
  43. package/test/unit/spec/services/service-catalog.js +93 -11
  44. package/test/unit/spec/services/services.js +491 -2
  45. package/test/unit/spec/services-v2/service-catalog.ts +93 -11
  46. package/test/unit/spec/services-v2/services-v2.ts +498 -0
@@ -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
  /**
@@ -57,6 +57,22 @@ const Services = WebexPlugin.extend({
57
57
  initFailed: ['boolean', false, false],
58
58
  },
59
59
 
60
+ session: {
61
+ /**
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".
66
+ * @instance
67
+ * @memberof Services
68
+ * @type {boolean}
69
+ */
70
+ ready: {
71
+ default: false,
72
+ type: 'boolean',
73
+ },
74
+ },
75
+
60
76
  _catalogs: new WeakMap(),
61
77
 
62
78
  _serviceUrls: null,
@@ -243,6 +259,28 @@ const Services = WebexPlugin.extend({
243
259
 
244
260
  return !!hostCatalog[host]?.length;
245
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
+
246
284
  /**
247
285
  * Merge provided active cluster mappings into current state.
248
286
  * @param {Record<string,string>} activeServices
@@ -1324,6 +1362,7 @@ const Services = WebexPlugin.extend({
1324
1362
 
1325
1363
  // Destructure the credentials plugin.
1326
1364
  const {credentials} = this.webex;
1365
+ const catalog = this._getCatalog();
1327
1366
 
1328
1367
  // Init a promise chain. Must be done as a Promise.resolve() to allow
1329
1368
  // credentials#getOrgId() to properly throw.
@@ -1336,12 +1375,18 @@ const Services = WebexPlugin.extend({
1336
1375
  .then(() => {
1337
1376
  // Validate if the token is authorized.
1338
1377
  if (credentials.canAuthorize) {
1339
- // Attempt to collect the postauth catalog.
1340
-
1341
- return this.updateServices().catch(() => {
1342
- this.initFailed = true;
1343
- this.logger.warn('services: cannot retrieve postauth catalog');
1344
- });
1378
+ // Attempt to collect the postauth catalog, then mark the catalog
1379
+ // ready. Setting `isReady` here - rather than only in the init
1380
+ // callers - means a slow postauth fetch that loses the gated-init
1381
+ // timeout race still marks the catalog ready once it completes.
1382
+ return this.updateServices()
1383
+ .then(() => {
1384
+ catalog.isReady = true;
1385
+ })
1386
+ .catch(() => {
1387
+ this.initFailed = true;
1388
+ this.logger.warn('services: cannot retrieve postauth catalog');
1389
+ });
1345
1390
  }
1346
1391
 
1347
1392
  // Return a resolved promise for consistent return value.
@@ -1350,6 +1395,51 @@ const Services = WebexPlugin.extend({
1350
1395
  );
1351
1396
  },
1352
1397
 
1398
+ /**
1399
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1400
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1401
+ * refresh is in flight when initial catalog collection settles, we must not
1402
+ * signal ready until the refresh has resolved - otherwise downstream
1403
+ * consumers may observe `canAuthorize`/token state that is about to change
1404
+ * under them.
1405
+ *
1406
+ * @private
1407
+ * @returns {Promise<void>}
1408
+ */
1409
+ async _finalizeReady() {
1410
+ const {credentials} = this.webex;
1411
+
1412
+ if (credentials && credentials.isRefreshing) {
1413
+ await new Promise((resolve) => {
1414
+ credentials.once('change:isRefreshing', resolve);
1415
+ });
1416
+ }
1417
+
1418
+ this.ready = true;
1419
+ },
1420
+
1421
+ /**
1422
+ * Build a promise that rejects once the catalog init timeout elapses. Race
1423
+ * this against catalog collection so a hung request never leaves
1424
+ * `services.ready` false forever - that would stall `webex.ready` and leave
1425
+ * consumers waiting on it indefinitely. Timeout is configurable via
1426
+ * `config.services.catalogInitTimeout` (defaults to 15s in config). Created
1427
+ * lazily so paths that skip catalog collection never schedule a stray timer.
1428
+ *
1429
+ * @private
1430
+ * @returns {Promise<never>}
1431
+ */
1432
+ _makeInitTimeout() {
1433
+ const initTimeoutMs = this.webex.config?.services?.catalogInitTimeout;
1434
+
1435
+ return new Promise((_, reject) => {
1436
+ setTimeout(
1437
+ () => reject(new Error(`services: init timed out after ${initTimeoutMs}ms`)),
1438
+ initTimeoutMs
1439
+ );
1440
+ });
1441
+ },
1442
+
1353
1443
  /**
1354
1444
  * Initializer
1355
1445
  *
@@ -1366,11 +1456,39 @@ const Services = WebexPlugin.extend({
1366
1456
  this.registries.set(this.webex, registry);
1367
1457
  this.states.set(this.webex, state);
1368
1458
 
1369
- // Listen for configuration changes once.
1459
+ // Listen for configuration changes once. The config is not populated on the
1460
+ // webex instance until the `change:config` event fires, so any decision that
1461
+ // depends on config values (such as the gated-vs-ungated init below) must be
1462
+ // made from within this handler rather than synchronously in `initialize()`.
1370
1463
  this.listenToOnce(this.webex, 'change:config', () => {
1371
1464
  this.initConfig();
1465
+
1466
+ // Feature flag: when enabled, `webex.ready` is blocked until the initial
1467
+ // catalog collection has settled AND any in-flight credentials refresh has
1468
+ // completed. When disabled (the default), preserves the pre-existing
1469
+ // behavior where `webex.ready` fires as soon as `webex.loaded` does and
1470
+ // the catalog is collected out-of-band.
1471
+ const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
1472
+
1473
+ if (waitForCatalogInit) {
1474
+ this._initializeCatalogsGated(catalog);
1475
+ } else {
1476
+ // Not gating - immediately mark ready so we do not block webex.ready.
1477
+ this.ready = true;
1478
+ this._initializeCatalogsUngated(catalog);
1479
+ }
1372
1480
  });
1481
+ },
1373
1482
 
1483
+ /**
1484
+ * Original (pre-verified-ready) initialization path. Runs on `webex.ready`
1485
+ * and collects catalogs opportunistically without blocking anything.
1486
+ *
1487
+ * @private
1488
+ * @param {ServiceCatalog} catalog
1489
+ * @returns {void}
1490
+ */
1491
+ _initializeCatalogsUngated(catalog) {
1374
1492
  // wait for webex instance to be ready before attempting
1375
1493
  // to update the service catalogs
1376
1494
  // this can cause a race condition because credentials may
@@ -1385,19 +1503,25 @@ const Services = WebexPlugin.extend({
1385
1503
  const {supertoken} = this.webex.credentials;
1386
1504
  // Validate if the supertoken exists.
1387
1505
  if (supertoken && supertoken.access_token) {
1388
- this.initServiceCatalogs()
1389
- .then(() => {
1390
- catalog.isReady = true;
1391
- })
1392
- .catch((error) => {
1393
- this.initFailed = true;
1394
- this.logger.error(
1395
- `services: failed to init initial services when credentials available, ${error?.message}`
1396
- );
1397
- });
1506
+ // `initServiceCatalogs` marks the catalog ready internally once the
1507
+ // postauth catalog is collected.
1508
+ this.initServiceCatalogs().catch((error) => {
1509
+ this.initFailed = true;
1510
+ this.logger.error(
1511
+ `services: failed to init initial services when credentials available, ${error?.message}`
1512
+ );
1513
+ });
1398
1514
  } else {
1399
1515
  const {email} = this.webex.config;
1400
1516
 
1517
+ if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
1518
+ this.logger.info(
1519
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
1520
+ );
1521
+
1522
+ return;
1523
+ }
1524
+
1401
1525
  this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1402
1526
  this.initFailed = true;
1403
1527
  this.logger.error(
@@ -1407,6 +1531,85 @@ const Services = WebexPlugin.extend({
1407
1531
  }
1408
1532
  });
1409
1533
  },
1534
+
1535
+ /**
1536
+ * Verified-ready initialization path. Blocks `webex.ready` until the initial
1537
+ * catalog fetch has settled (or timed out) AND any in-flight credentials
1538
+ * refresh has completed. Also handles the fresh-login case where OAuth
1539
+ * completes after `loaded` fires.
1540
+ *
1541
+ * @private
1542
+ * @param {ServiceCatalog} catalog
1543
+ * @returns {void}
1544
+ */
1545
+ _initializeCatalogsGated(catalog) {
1546
+ // Wait for storage to be loaded before attempting to update the service
1547
+ // catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
1548
+ // now blocks `webex.ready` - listening to 'ready' would deadlock.
1549
+ this.listenToOnce(this.webex, 'loaded', async () => {
1550
+ const cachedCatalog = await this._loadCatalogFromCache();
1551
+ if (cachedCatalog) {
1552
+ catalog.isReady = true;
1553
+ await this._finalizeReady();
1554
+
1555
+ return; // skip initServiceCatalogs() on reload when cache exists
1556
+ }
1557
+ const {supertoken} = this.webex.credentials;
1558
+
1559
+ // Validate if the supertoken exists.
1560
+ if (supertoken && supertoken.access_token) {
1561
+ // `initServiceCatalogs` marks the catalog ready internally once the
1562
+ // postauth catalog is collected - even if it loses the timeout race
1563
+ // below, so a slow fetch still eventually flips `catalog.isReady`.
1564
+ Promise.race([this.initServiceCatalogs(), this._makeInitTimeout()])
1565
+ .catch((error) => {
1566
+ this.initFailed = true;
1567
+ this.logger.error(
1568
+ `services: failed to init initial services when credentials available, ${error?.message}`
1569
+ );
1570
+ })
1571
+ .finally(() => this._finalizeReady());
1572
+ } else {
1573
+ const {email} = this.webex.config;
1574
+
1575
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1576
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1577
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1578
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1579
+ // `initServiceCatalogs` marks the catalog ready internally.
1580
+ this.initServiceCatalogs().catch((error) => {
1581
+ this.logger.error(
1582
+ `services: failed to init service catalogs after auth, ${error?.message}`
1583
+ );
1584
+ });
1585
+ }
1586
+ });
1587
+
1588
+ if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
1589
+ // Skip the preauth catalog fetch (it will be collected manually
1590
+ // later), but still finalize `services.ready` so `webex.ready` is not
1591
+ // stalled while unauthenticated. No timeout is created here so there
1592
+ // is no stray timer or unhandled rejection.
1593
+ this.logger.info(
1594
+ 'services: skipping preauth catalog collection while unauthenticated as per the config'
1595
+ );
1596
+ this._finalizeReady();
1597
+ } else {
1598
+ Promise.race([
1599
+ this.collectPreauthCatalog(email ? {email} : undefined),
1600
+ this._makeInitTimeout(),
1601
+ ])
1602
+ .catch((error) => {
1603
+ this.initFailed = true;
1604
+ this.logger.error(
1605
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1606
+ );
1607
+ })
1608
+ .finally(() => this._finalizeReady());
1609
+ }
1610
+ }
1611
+ });
1612
+ },
1410
1613
  });
1411
1614
  /* eslint-enable no-underscore-dangle */
1412
1615
 
@@ -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
  /**