@webex/webex-core 3.12.0-next.5 → 3.12.0-next.50

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 (56) hide show
  1. package/README.md +5 -2
  2. package/dist/config.js +38 -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/index.js +7 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/interceptors/catalog-url.js +87 -0
  9. package/dist/interceptors/catalog-url.js.map +1 -0
  10. package/dist/interceptors/payload-transformer.js +25 -4
  11. package/dist/interceptors/payload-transformer.js.map +1 -1
  12. package/dist/lib/batcher.js +23 -7
  13. package/dist/lib/batcher.js.map +1 -1
  14. package/dist/lib/credentials/credentials.js +103 -4
  15. package/dist/lib/credentials/credentials.js.map +1 -1
  16. package/dist/lib/credentials/token.js +1 -1
  17. package/dist/lib/domains.js +90 -0
  18. package/dist/lib/domains.js.map +1 -0
  19. package/dist/lib/services/service-catalog.js +108 -18
  20. package/dist/lib/services/service-catalog.js.map +1 -1
  21. package/dist/lib/services/services.js +232 -34
  22. package/dist/lib/services/services.js.map +1 -1
  23. package/dist/lib/services-v2/service-catalog.js +14 -13
  24. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  25. package/dist/lib/services-v2/services-v2.js +241 -37
  26. package/dist/lib/services-v2/services-v2.js.map +1 -1
  27. package/dist/plugins/logger.js +1 -1
  28. package/dist/webex-core.js +11 -3
  29. package/dist/webex-core.js.map +1 -1
  30. package/package.json +13 -13
  31. package/src/config.js +42 -0
  32. package/src/credentials-config.js +13 -0
  33. package/src/index.js +1 -0
  34. package/src/interceptors/catalog-url.js +66 -0
  35. package/src/interceptors/payload-transformer.js +23 -1
  36. package/src/lib/batcher.js +25 -10
  37. package/src/lib/credentials/credentials.js +93 -3
  38. package/src/lib/domains.ts +94 -0
  39. package/src/lib/services/service-catalog.js +112 -16
  40. package/src/lib/services/services.js +208 -20
  41. package/src/lib/services-v2/service-catalog.ts +16 -12
  42. package/src/lib/services-v2/services-v2.ts +218 -21
  43. package/src/webex-core.js +8 -0
  44. package/test/fixtures/activation-email.ts +22 -0
  45. package/test/integration/spec/services/services.js +183 -112
  46. package/test/integration/spec/services-v2/services-v2.js +169 -98
  47. package/test/unit/spec/credentials/credentials.js +247 -3
  48. package/test/unit/spec/interceptors/auth.js +56 -0
  49. package/test/unit/spec/interceptors/catalog-url.js +224 -0
  50. package/test/unit/spec/interceptors/payload-transformer.js +119 -1
  51. package/test/unit/spec/lib/batcher.js +56 -0
  52. package/test/unit/spec/services/service-catalog.js +368 -11
  53. package/test/unit/spec/services/services.js +440 -2
  54. package/test/unit/spec/services-v2/service-catalog.ts +134 -11
  55. package/test/unit/spec/services-v2/services-v2.ts +467 -0
  56. package/test/unit/spec/webex-core.js +77 -1
@@ -6,7 +6,7 @@ import querystring from 'querystring';
6
6
  import url from 'url';
7
7
 
8
8
  import jwt from 'jsonwebtoken';
9
- import {base64, makeStateDataType, oneFlight, tap, whileInFlight} from '@webex/common';
9
+ import {base64, encodeState, makeStateDataType, oneFlight, tap, whileInFlight} from '@webex/common';
10
10
  import {safeSetTimeout} from '@webex/common-timers';
11
11
  import {clone, cloneDeep, isObject, isEmpty} from 'lodash';
12
12
 
@@ -109,7 +109,7 @@ const Credentials = WebexPlugin.extend({
109
109
  */
110
110
  buildLoginUrl(options = {clientType: 'public'}) {
111
111
  /* eslint-disable camelcase */
112
- if (options.state && !isObject(options.state)) {
112
+ if (options.state !== undefined && !isObject(options.state)) {
113
113
  throw new Error('if specified, `options.state` must be an object');
114
114
  }
115
115
 
@@ -126,7 +126,7 @@ const Credentials = WebexPlugin.extend({
126
126
 
127
127
  if (options.state) {
128
128
  if (!isEmpty(options.state)) {
129
- options.state = base64.toBase64Url(JSON.stringify(options.state));
129
+ options.state = encodeState(options.state);
130
130
  } else {
131
131
  delete options.state;
132
132
  }
@@ -212,6 +212,96 @@ const Credentials = WebexPlugin.extend({
212
212
  return fields[2];
213
213
  },
214
214
 
215
+ /**
216
+ * Extract the CI user ID [cis_uuid] from a provided token.
217
+ *
218
+ * @private
219
+ * @param {string} token - The access token to extract the user ID from.
220
+ * @throws {Error} - If the token cannot be parsed or does not contain a user ID.
221
+ * @returns {string} - The CI user ID.
222
+ */
223
+ extractUserIdFromToken(token = '') {
224
+ // User tokens are JWT-like; the middle section holds a base64-encoded JSON payload.
225
+ const payload = JSON.parse(base64.decode(token.split('.')[1]));
226
+
227
+ if (!payload.cis_uuid) {
228
+ throw new Error('the provided token does not contain a user ID');
229
+ }
230
+
231
+ return payload.cis_uuid;
232
+ },
233
+
234
+ /**
235
+ * Get the CI user ID [cis_uuid] of the currently authenticated user.
236
+ *
237
+ * Checks the supertoken first, then falls back to any stored user tokens.
238
+ *
239
+ * @throws {Error} - If the user ID could not be determined from any token.
240
+ * @returns {string} - The CI user ID.
241
+ */
242
+ getUserId() {
243
+ const tokens = [this.supertoken, ...this.userTokens.models];
244
+
245
+ for (const token of tokens) {
246
+ if (token && token.access_token) {
247
+ try {
248
+ return this.extractUserIdFromToken(token.access_token);
249
+ } catch {
250
+ // token wasn't parseable or lacked a user ID; try the next one
251
+ }
252
+ }
253
+ }
254
+
255
+ throw new Error('could not extract the user ID from any available token');
256
+ },
257
+
258
+ /**
259
+ * Generates a Third-Party Login URL pointing at IdBroker's
260
+ * `/idb/ThirdPartyLogin` endpoint. Used by the social-provider sign-in
261
+ * flow (Google / Microsoft / Apple / ...).
262
+ *
263
+ * Mirrors `buildLoginUrl` / `buildLogoutUrl` — pure URL construction,
264
+ * no navigation side effects. Reads from `this.config.thirdPartyLoginUrl`,
265
+ * which is derived from `idbroker.url` in `credentials-config.js`.
266
+ *
267
+ * @instance
268
+ * @memberof Credentials
269
+ * @param {Object} options
270
+ * @param {string} options.oauth2provider - Provider name (`google`,
271
+ * `microsoft`, `apple`, ...). Required.
272
+ * @param {string} options.returnURL - URL IdBroker should send the user
273
+ * back to after the third-party hand-off. Required.
274
+ * @param {Object} [options.state] - Optional state object. When non-empty
275
+ * it is JSON-stringified and base64url-encoded, then emitted as the
276
+ * top-level `state` query param so IdBroker can echo it back unchanged
277
+ * on the callback (mirrors `buildLoginUrl`).
278
+ * @returns {string}
279
+ */
280
+ buildThirdPartyLoginUrl(options = {}) {
281
+ const {oauth2provider, returnURL, state} = options;
282
+
283
+ if (!oauth2provider) {
284
+ throw new Error('`options.oauth2provider` is required');
285
+ }
286
+ if (!returnURL) {
287
+ throw new Error('`options.returnURL` is required');
288
+ }
289
+ if (state !== undefined && !isObject(state)) {
290
+ throw new Error('if specified, `options.state` must be an object');
291
+ }
292
+
293
+ const query = {
294
+ oauth2provider,
295
+ returnURL,
296
+ };
297
+
298
+ if (state && !isEmpty(state)) {
299
+ query.state = encodeState(state);
300
+ }
301
+
302
+ return `${this.config.thirdPartyLoginUrl}?${querystring.stringify(query)}`;
303
+ },
304
+
215
305
  /**
216
306
  * Generates a Logout URL
217
307
  * @instance
@@ -0,0 +1,94 @@
1
+ import Url from 'url';
2
+
3
+ import {uniq} from 'lodash';
4
+
5
+ // Canonicalise a hostname for comparison: lowercase, drop the brackets around
6
+ // an IPv6 literal, and drop leading/trailing dots. DNS treats `Example.com`,
7
+ // `example.com.` and `example.com` as the same name.
8
+ //
9
+ // Node's `url.domainToASCII` looks like the standard way to do this, but the
10
+ // `url` polyfill this package bundles for the browser does not implement it,
11
+ // so it cannot be used here. It also leaves trailing dots in place.
12
+ const normalizeHostname = (value: string): string =>
13
+ typeof value === 'string'
14
+ ? value
15
+ .toLowerCase()
16
+ .replace(/^\[|\]$/g, '')
17
+ .replace(/^\.+/, '')
18
+ .replace(/\.+$/, '')
19
+ : '';
20
+
21
+ /**
22
+ * Canonicalise a list of configured allowed domains, discarding any entry that
23
+ * is not a usable hostname. Callers normalise on the way in so the stored list
24
+ * is already canonical, rather than re-deriving it on every request.
25
+ *
26
+ * @param {Array<string>} allowedDomains - The configured allowed domains.
27
+ * @returns {Array<string>} - Normalized, de-duplicated, non-empty entries.
28
+ */
29
+ export const normalizeAllowedDomains = (allowedDomains: Array<string>): Array<string> =>
30
+ uniq(
31
+ (Array.isArray(allowedDomains) ? allowedDomains : []).map(normalizeHostname).filter(Boolean)
32
+ );
33
+
34
+ /**
35
+ * Determine if a hostname is covered by an allowed domain, matching only on DNS
36
+ * label boundaries, so that a hostname is allowed only when it is the domain
37
+ * itself or a subdomain of it. Matching on a substring instead would treat
38
+ * unrelated hostnames that merely contain the domain as allowed.
39
+ *
40
+ * @param {string} hostname - Hostname to test. Must not include a port.
41
+ * @param {string} allowedDomain - The configured allowed domain.
42
+ * @returns {boolean} - True when the hostname is the domain or a subdomain of it.
43
+ */
44
+ const hostnameMatchesDomain = (hostname: string, allowedDomain: string): boolean => {
45
+ // The stored list is normalized on write, but `allowedDomains` is a public
46
+ // property, so normalize again here rather than trust it.
47
+ const host = normalizeHostname(hostname);
48
+ const domain = normalizeHostname(allowedDomain);
49
+
50
+ return !!host && !!domain && (host === domain || host.endsWith(`.${domain}`));
51
+ };
52
+
53
+ /**
54
+ * Find the allowed domain covering a url, or `undefined` if there is none.
55
+ *
56
+ * Parsing lives here rather than in the callers, and deliberately uses both url
57
+ * parsers, because this check gates an `Authorization` header. The two
58
+ * transports behind `@webex/http-core` do not use the same url parser: the
59
+ * browser transport parses per WHATWG, the node transport uses Node's legacy
60
+ * `Url.parse`, and for some inputs the two resolve different hosts.
61
+ *
62
+ * Rather than picking one, require both to agree and fail closed when they do
63
+ * not, so this check can never authorize a host that differs from the one a
64
+ * transport would actually connect to. Do not narrow this to a single parser.
65
+ *
66
+ * @param {string} url - The url to match the allowed domains against.
67
+ * @param {Array<string>} allowedDomains - The configured allowed domains.
68
+ * @returns {string} - The matching allowed domain, or undefined if there is none.
69
+ */
70
+ export const matchAllowedDomain = (
71
+ url: string,
72
+ allowedDomains: Array<string>
73
+ ): string | undefined => {
74
+ let hostname: string;
75
+ let legacyHostname: string;
76
+
77
+ try {
78
+ ({hostname} = new URL(url));
79
+ ({hostname: legacyHostname} = Url.parse(url));
80
+ } catch {
81
+ // Not a parsable absolute url, so it cannot belong to an allowed domain.
82
+ return undefined;
83
+ }
84
+
85
+ if (normalizeHostname(hostname) !== normalizeHostname(legacyHostname)) {
86
+ return undefined;
87
+ }
88
+
89
+ return (allowedDomains || []).find((allowedDomain) =>
90
+ hostnameMatchesDomain(hostname, allowedDomain)
91
+ );
92
+ };
93
+
94
+ export default matchAllowedDomain;
@@ -4,6 +4,100 @@ 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';
8
+
9
+ // Catalog base URLs are a small, stable set, so their parsed origin/path is
10
+ // memoized to avoid repeated `new URL()` work when scanning the catalog on the
11
+ // request hot path.
12
+ const catalogUrlCache = new Map();
13
+
14
+ /**
15
+ * Parse a catalog URL into the origin and normalized path used for matching,
16
+ * memoizing the result. Returns null when the URL is unparsable.
17
+ *
18
+ * @param {string} catalogUrlString - The catalog URL to parse
19
+ * @returns {{origin: string, path: string} | null} - Parsed catalog URL, or null
20
+ */
21
+ export function parseCatalogUrl(catalogUrlString) {
22
+ if (catalogUrlCache.has(catalogUrlString)) {
23
+ return catalogUrlCache.get(catalogUrlString);
24
+ }
25
+
26
+ let parsed = null;
27
+
28
+ try {
29
+ const catalogUrl = new URL(catalogUrlString);
30
+
31
+ // Normalize paths by removing trailing slashes (except root "/")
32
+ parsed = {origin: catalogUrl.origin, path: catalogUrl.pathname.replace(/\/$/, '') || '/'};
33
+ } catch {
34
+ parsed = null;
35
+ }
36
+
37
+ catalogUrlCache.set(catalogUrlString, parsed);
38
+
39
+ return parsed;
40
+ }
41
+
42
+ /**
43
+ * Check if an already-parsed candidate URL matches a parsed catalog URL with
44
+ * proper origin validation. Allocates nothing, so it is safe to call in a tight
45
+ * loop over the whole catalog.
46
+ *
47
+ * @param {URL} candidateUrl - The parsed candidate URL to validate
48
+ * @param {{origin: string, path: string} | null} parsedCatalogUrl - The parsed catalog URL
49
+ * @returns {boolean} - True if the candidate URL is under the catalog URL's origin and path
50
+ */
51
+ export function matchesParsedCatalogUrl(candidateUrl, parsedCatalogUrl) {
52
+ if (!candidateUrl || !parsedCatalogUrl) {
53
+ return false;
54
+ }
55
+
56
+ // Origins must match exactly (scheme + host + port)
57
+ if (candidateUrl.origin !== parsedCatalogUrl.origin) {
58
+ return false;
59
+ }
60
+
61
+ const catalogPath = parsedCatalogUrl.path;
62
+
63
+ if (catalogPath === '/') {
64
+ // Root path matches everything under this origin
65
+ return true;
66
+ }
67
+
68
+ const candidatePath = candidateUrl.pathname;
69
+
70
+ if (candidatePath.startsWith(catalogPath)) {
71
+ // Ensure we're at a path boundary, not mid-segment
72
+ // e.g., /api/v1 should match /api/v1/foo but not /api/v1extra
73
+ // nextChar is undefined for exact match, '/' for valid extension
74
+ const nextChar = candidatePath[catalogPath.length];
75
+
76
+ return nextChar === '/' || nextChar === undefined;
77
+ }
78
+
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Check if a candidate URL matches a catalog URL with proper origin validation.
84
+ * This prevents bypasses like https://trusted.example.attacker.com matching https://trusted.example
85
+ *
86
+ * @param {string} candidateUrlString - The URL to validate
87
+ * @param {string} catalogUrlString - The catalog URL to compare against
88
+ * @returns {boolean} - True if the candidate URL is under the catalog URL's origin and path
89
+ */
90
+ export function matchesCatalogUrl(candidateUrlString, catalogUrlString) {
91
+ let candidateUrl;
92
+
93
+ try {
94
+ candidateUrl = new URL(candidateUrlString);
95
+ } catch {
96
+ return false;
97
+ }
98
+
99
+ return matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(catalogUrlString));
100
+ }
7
101
 
8
102
  /* eslint-disable no-underscore-dangle */
9
103
  /**
@@ -245,20 +339,27 @@ const ServiceCatalog = AmpState.extend({
245
339
  ...this.serviceGroups.override,
246
340
  ];
247
341
 
342
+ // Invalid URLs cannot match any service
343
+ let candidateUrl;
344
+
345
+ try {
346
+ candidateUrl = new URL(url);
347
+ } catch {
348
+ return undefined;
349
+ }
350
+
248
351
  return serviceUrls.find((serviceUrl) => {
249
- // Check to see if the URL we are checking starts with the default URL
250
- if (url.startsWith(serviceUrl.defaultUrl)) {
352
+ // Check if the URL matches the default URL with proper origin validation
353
+ if (matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(serviceUrl.defaultUrl))) {
251
354
  return true;
252
355
  }
253
356
 
254
- // If not, we check to see if the alternate URLs match
255
- // These are made by swapping the host of the default URL
256
- // with that of an alternate host
357
+ // Check alternate URLs (built by swapping host with alternate hosts)
257
358
  for (const host of serviceUrl.hosts) {
258
359
  const alternateUrl = new URL(serviceUrl.defaultUrl);
259
360
  alternateUrl.host = host.host;
260
361
 
261
- if (url.startsWith(alternateUrl.toString())) {
362
+ if (matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(alternateUrl.toString()))) {
262
363
  return true;
263
364
  }
264
365
  }
@@ -268,19 +369,14 @@ const ServiceCatalog = AmpState.extend({
268
369
  },
269
370
 
270
371
  /**
271
- * Finds an allowed domain that matches a specific url.
372
+ * Finds an allowed domain that matches a specific url. The url's hostname
373
+ * must be the allowed domain itself or a subdomain of it.
272
374
  *
273
375
  * @param {string} url - The url to match the allowed domains against.
274
376
  * @returns {string} - The matching allowed domain.
275
377
  */
276
378
  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));
379
+ return matchAllowedDomain(url, this.allowedDomains);
284
380
  },
285
381
 
286
382
  /**
@@ -367,7 +463,7 @@ const ServiceCatalog = AmpState.extend({
367
463
  * @returns {void}
368
464
  */
369
465
  setAllowedDomains(allowedDomains) {
370
- this.allowedDomains = [...allowedDomains];
466
+ this.allowedDomains = normalizeAllowedDomains(allowedDomains);
371
467
  },
372
468
 
373
469
  /**
@@ -376,7 +472,7 @@ const ServiceCatalog = AmpState.extend({
376
472
  * @returns {void}
377
473
  */
378
474
  addAllowedDomains(newAllowedDomains) {
379
- this.allowedDomains = union(this.allowedDomains, newAllowedDomains);
475
+ this.allowedDomains = union(this.allowedDomains, normalizeAllowedDomains(newAllowedDomains));
380
476
  },
381
477
 
382
478
  /**
@@ -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,
@@ -1087,9 +1103,16 @@ const Services = WebexPlugin.extend({
1087
1103
  requestObject.headers = {authorization: token};
1088
1104
  }
1089
1105
 
1090
- return this.webex.internal.newMetrics.callDiagnosticLatencies
1091
- .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time')
1092
- .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);
1093
1116
  },
1094
1117
 
1095
1118
  /**
@@ -1346,6 +1369,7 @@ const Services = WebexPlugin.extend({
1346
1369
 
1347
1370
  // Destructure the credentials plugin.
1348
1371
  const {credentials} = this.webex;
1372
+ const catalog = this._getCatalog();
1349
1373
 
1350
1374
  // Init a promise chain. Must be done as a Promise.resolve() to allow
1351
1375
  // credentials#getOrgId() to properly throw.
@@ -1358,12 +1382,18 @@ const Services = WebexPlugin.extend({
1358
1382
  .then(() => {
1359
1383
  // Validate if the token is authorized.
1360
1384
  if (credentials.canAuthorize) {
1361
- // Attempt to collect the postauth catalog.
1362
-
1363
- return this.updateServices().catch(() => {
1364
- this.initFailed = true;
1365
- this.logger.warn('services: cannot retrieve postauth catalog');
1366
- });
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
+ });
1367
1397
  }
1368
1398
 
1369
1399
  // Return a resolved promise for consistent return value.
@@ -1372,6 +1402,51 @@ const Services = WebexPlugin.extend({
1372
1402
  );
1373
1403
  },
1374
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
+
1375
1450
  /**
1376
1451
  * Initializer
1377
1452
  *
@@ -1388,11 +1463,39 @@ const Services = WebexPlugin.extend({
1388
1463
  this.registries.set(this.webex, registry);
1389
1464
  this.states.set(this.webex, state);
1390
1465
 
1391
- // 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()`.
1392
1470
  this.listenToOnce(this.webex, 'change:config', () => {
1393
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
+ }
1394
1487
  });
1488
+ },
1395
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) {
1396
1499
  // wait for webex instance to be ready before attempting
1397
1500
  // to update the service catalogs
1398
1501
  // this can cause a race condition because credentials may
@@ -1407,19 +1510,25 @@ const Services = WebexPlugin.extend({
1407
1510
  const {supertoken} = this.webex.credentials;
1408
1511
  // Validate if the supertoken exists.
1409
1512
  if (supertoken && supertoken.access_token) {
1410
- this.initServiceCatalogs()
1411
- .then(() => {
1412
- catalog.isReady = true;
1413
- })
1414
- .catch((error) => {
1415
- this.initFailed = true;
1416
- this.logger.error(
1417
- `services: failed to init initial services when credentials available, ${error?.message}`
1418
- );
1419
- });
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
+ });
1420
1521
  } else {
1421
1522
  const {email} = this.webex.config;
1422
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
+
1423
1532
  this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1424
1533
  this.initFailed = true;
1425
1534
  this.logger.error(
@@ -1429,6 +1538,85 @@ const Services = WebexPlugin.extend({
1429
1538
  }
1430
1539
  });
1431
1540
  },
1541
+
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.
1556
+ this.listenToOnce(this.webex, 'loaded', async () => {
1557
+ const cachedCatalog = await this._loadCatalogFromCache();
1558
+ if (cachedCatalog) {
1559
+ catalog.isReady = true;
1560
+ await this._finalizeReady();
1561
+
1562
+ return; // skip initServiceCatalogs() on reload when cache exists
1563
+ }
1564
+ const {supertoken} = this.webex.credentials;
1565
+
1566
+ // Validate if the supertoken exists.
1567
+ if (supertoken && supertoken.access_token) {
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()])
1572
+ .catch((error) => {
1573
+ this.initFailed = true;
1574
+ this.logger.error(
1575
+ `services: failed to init initial services when credentials available, ${error?.message}`
1576
+ );
1577
+ })
1578
+ .finally(() => this._finalizeReady());
1579
+ } else {
1580
+ const {email} = this.webex.config;
1581
+
1582
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1583
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1584
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1585
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
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
+ });
1592
+ }
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
+ }
1617
+ }
1618
+ });
1619
+ },
1432
1620
  });
1433
1621
  /* eslint-enable no-underscore-dangle */
1434
1622