@webex/webex-core 3.8.0 → 3.8.1-next.10

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 (79) hide show
  1. package/README.md +87 -27
  2. package/dist/index.js +28 -9
  3. package/dist/index.js.map +1 -1
  4. package/dist/interceptors/redirect.js +18 -0
  5. package/dist/interceptors/redirect.js.map +1 -1
  6. package/dist/lib/batcher.js +1 -1
  7. package/dist/lib/constants.js +10 -1
  8. package/dist/lib/constants.js.map +1 -1
  9. package/dist/lib/credentials/credentials.js +1 -1
  10. package/dist/lib/credentials/token.js +1 -1
  11. package/dist/lib/{services/interceptors → interceptors}/server-error.js +1 -1
  12. package/dist/lib/interceptors/server-error.js.map +1 -0
  13. package/dist/lib/services/index.js +2 -29
  14. package/dist/lib/services/index.js.map +1 -1
  15. package/dist/lib/services/service-host.js +1 -1
  16. package/dist/lib/services/service-host.js.map +1 -1
  17. package/dist/lib/services/service-registry.js +1 -1
  18. package/dist/lib/services/service-registry.js.map +1 -1
  19. package/dist/lib/services/service-state.js +1 -1
  20. package/dist/lib/services/service-state.js.map +1 -1
  21. package/dist/lib/services/services.js +3 -3
  22. package/dist/lib/services/services.js.map +1 -1
  23. package/dist/lib/services-v2/index.js +29 -0
  24. package/dist/lib/services-v2/index.js.map +1 -0
  25. package/dist/lib/services-v2/metrics.js +12 -0
  26. package/dist/lib/services-v2/metrics.js.map +1 -0
  27. package/dist/lib/services-v2/service-catalog.js +347 -0
  28. package/dist/lib/services-v2/service-catalog.js.map +1 -0
  29. package/dist/lib/services-v2/service-detail.js +94 -0
  30. package/dist/lib/services-v2/service-detail.js.map +1 -0
  31. package/dist/lib/services-v2/service-fed-ramp.js +13 -0
  32. package/dist/lib/services-v2/service-fed-ramp.js.map +1 -0
  33. package/dist/lib/services-v2/services-v2.js +974 -0
  34. package/dist/lib/services-v2/services-v2.js.map +1 -0
  35. package/dist/lib/services-v2/types.js +7 -0
  36. package/dist/lib/services-v2/types.js.map +1 -0
  37. package/dist/plugins/logger.js +1 -1
  38. package/dist/webex-core.js +3 -3
  39. package/dist/webex-core.js.map +1 -1
  40. package/package.json +13 -13
  41. package/src/index.js +5 -4
  42. package/src/interceptors/redirect.js +28 -0
  43. package/src/lib/constants.js +29 -1
  44. package/src/lib/{services/interceptors → interceptors}/server-error.js +1 -1
  45. package/src/lib/services/index.js +2 -7
  46. package/src/lib/services/service-host.js +1 -1
  47. package/src/lib/services/service-registry.js +1 -1
  48. package/src/lib/services/service-state.js +1 -1
  49. package/src/lib/services/services.js +2 -2
  50. package/src/lib/services-v2/README.md +3 -0
  51. package/src/lib/services-v2/index.ts +7 -0
  52. package/src/lib/services-v2/metrics.ts +4 -0
  53. package/src/lib/services-v2/service-catalog.ts +361 -0
  54. package/src/lib/services-v2/service-detail.ts +97 -0
  55. package/src/lib/services-v2/service-fed-ramp.ts +5 -0
  56. package/src/lib/services-v2/services-v2.ts +1010 -0
  57. package/src/lib/services-v2/types.ts +73 -0
  58. package/src/webex-core.js +1 -1
  59. package/test/fixtures/host-catalog-v2.ts +157 -0
  60. package/test/integration/spec/services/services.js +23 -10
  61. package/test/integration/spec/services-v2/service-catalog.js +664 -0
  62. package/test/integration/spec/services-v2/services-v2.js +1061 -0
  63. package/test/unit/spec/interceptors/redirect.js +72 -0
  64. package/test/unit/spec/services-v2/service-catalog.ts +288 -0
  65. package/test/unit/spec/services-v2/service-detail.ts +147 -0
  66. package/test/unit/spec/services-v2/services-v2.ts +516 -0
  67. package/dist/lib/services/constants.js +0 -17
  68. package/dist/lib/services/constants.js.map +0 -1
  69. package/dist/lib/services/interceptors/server-error.js.map +0 -1
  70. package/src/lib/services/constants.js +0 -21
  71. /package/dist/lib/{services/interceptors → interceptors}/hostmap.js +0 -0
  72. /package/dist/lib/{services/interceptors → interceptors}/hostmap.js.map +0 -0
  73. /package/dist/lib/{services/interceptors → interceptors}/service.js +0 -0
  74. /package/dist/lib/{services/interceptors → interceptors}/service.js.map +0 -0
  75. /package/dist/lib/{services/metrics.js → metrics.js} +0 -0
  76. /package/dist/lib/{services/metrics.js.map → metrics.js.map} +0 -0
  77. /package/src/lib/{services/interceptors → interceptors}/hostmap.js +0 -0
  78. /package/src/lib/{services/interceptors → interceptors}/service.js +0 -0
  79. /package/src/lib/{services/metrics.js → metrics.js} +0 -0
@@ -0,0 +1,1010 @@
1
+ import sha256 from 'crypto-js/sha256';
2
+
3
+ import {union, unionBy} from 'lodash';
4
+ import WebexPlugin from '../webex-plugin';
5
+
6
+ import METRICS from '../metrics';
7
+ import ServiceCatalog from './service-catalog';
8
+ import fedRampServices from './service-fed-ramp';
9
+ import {COMMERCIAL_ALLOWED_DOMAINS} from '../constants';
10
+ import {
11
+ ActiveServices,
12
+ IServiceCatalog,
13
+ QueryOptions,
14
+ Service,
15
+ ServiceHostmap,
16
+ ServiceGroup,
17
+ } from './types';
18
+
19
+ const trailingSlashes = /(?:^\/)|(?:\/$)/;
20
+
21
+ // The default cluster when one is not provided (usually as 'US' from hydra)
22
+ export const DEFAULT_CLUSTER = 'urn:TEAM:us-east-2_a';
23
+ // The default service name for convo (currently identityLookup due to some weird CSB issue)
24
+ export const DEFAULT_CLUSTER_SERVICE = 'identityLookup';
25
+
26
+ const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAULT_CLUSTER_SERVICE;
27
+ const DEFAULT_CLUSTER_IDENTIFIER =
28
+ process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`;
29
+
30
+ /* eslint-disable no-underscore-dangle */
31
+ /**
32
+ * @class
33
+ */
34
+ const Services = WebexPlugin.extend({
35
+ namespace: 'Services',
36
+
37
+ props: {
38
+ validateDomains: ['boolean', false, true],
39
+ initFailed: ['boolean', false, false],
40
+ },
41
+
42
+ _catalogs: new WeakMap(),
43
+
44
+ _activeServices: {},
45
+
46
+ _services: [],
47
+
48
+ /**
49
+ * @private
50
+ * Get the current catalog based on the assocaited
51
+ * webex instance.
52
+ * @returns {IServiceCatalog}
53
+ */
54
+ _getCatalog(): IServiceCatalog {
55
+ return this._catalogs.get(this.webex);
56
+ },
57
+
58
+ /**
59
+ * Get a service url from the current services list by name
60
+ * from the associated instance catalog.
61
+ * @param {string} name
62
+ * @param {ServiceGroup} [serviceGroup]
63
+ * @returns {string|undefined}
64
+ */
65
+ get(name: string, serviceGroup?: ServiceGroup): string | undefined {
66
+ const catalog = this._getCatalog();
67
+
68
+ const clusterId = this._activeServices[name];
69
+
70
+ const urlById = catalog.get(clusterId, serviceGroup);
71
+ const urlByName = catalog.get(name, serviceGroup);
72
+
73
+ // if both are undefined, then we cannot find the service
74
+ if (!urlById && !urlByName) {
75
+ return undefined;
76
+ }
77
+
78
+ return urlById || urlByName;
79
+ },
80
+
81
+ /**
82
+ * Determine if a whilelist exists in the service catalog.
83
+ *
84
+ * @returns {boolean} - True if a allowed domains list exists.
85
+ */
86
+ hasAllowedDomains(): boolean {
87
+ const catalog = this._getCatalog();
88
+
89
+ return catalog.getAllowedDomains().length > 0;
90
+ },
91
+
92
+ /**
93
+ * Mark a priority host service url as failed.
94
+ * This will mark the service url associated with the
95
+ * `ServiceDetail` to be removed from the its
96
+ * respective service url array, and then return the next
97
+ * viable service url from the `ServiceDetail` service url array.
98
+ * @param {string} url
99
+ * @returns {string}
100
+ */
101
+ markFailedUrl(url: string): string | undefined {
102
+ const catalog = this._getCatalog();
103
+
104
+ return catalog.markFailedServiceUrl(url);
105
+ },
106
+
107
+ /**
108
+ * saves all the services from the pre and post catalog service
109
+ * @param {ActiveServices} activeServices
110
+ * @returns {void}
111
+ */
112
+ _updateActiveServices(activeServices: ActiveServices): void {
113
+ this._activeServices = {...this._activeServices, ...activeServices};
114
+ },
115
+
116
+ /**
117
+ * saves the hostCatalog object
118
+ * @param {Array<Service>} services
119
+ * @returns {void}
120
+ */
121
+ _updateServices(services: Array<Service>): void {
122
+ this._services = unionBy(services, this._services, 'id');
123
+ },
124
+
125
+ /**
126
+ * Update a list of `serviceUrls` to the most current
127
+ * catalog via the defined `discoveryUrl` then returns the current
128
+ * list of services.
129
+ * @param {object} [param]
130
+ * @param {string} [param.from] - This accepts `limited` or `signin`
131
+ * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values
132
+ * @param {string} [param.query.email] - must be a standard-format email
133
+ * @param {string} [param.query.orgId] - must be an organization id
134
+ * @param {string} [param.query.userId] - must be a user id
135
+ * @param {string} [param.token] - used for signin catalog
136
+ * @returns {Promise<object>}
137
+ */
138
+ updateServices(
139
+ {from, query, token, forceRefresh} = {} as {
140
+ from: string;
141
+ query: QueryOptions;
142
+ token: string;
143
+ forceRefresh: boolean;
144
+ }
145
+ ): Promise<object> {
146
+ const catalog = this._getCatalog();
147
+ let formattedQuery;
148
+ let serviceGroup;
149
+
150
+ // map catalog name to service group name.
151
+ switch (from) {
152
+ case 'limited':
153
+ serviceGroup = 'preauth';
154
+ break;
155
+ case 'signin':
156
+ serviceGroup = 'signin';
157
+ break;
158
+ default:
159
+ serviceGroup = 'postauth';
160
+ break;
161
+ }
162
+
163
+ // confirm catalog update for group is not in progress.
164
+ if (catalog.status[serviceGroup].collecting) {
165
+ return this.waitForCatalog(serviceGroup);
166
+ }
167
+
168
+ catalog.status[serviceGroup].collecting = true;
169
+
170
+ if (serviceGroup === 'preauth') {
171
+ const queryKey = query && Object.keys(query)[0];
172
+
173
+ if (!['email', 'emailhash', 'userId', 'orgId', 'mode'].includes(queryKey)) {
174
+ return Promise.reject(
175
+ new Error('a query param of email, emailhash, userId, orgId, or mode is required')
176
+ );
177
+ }
178
+ }
179
+ // encode email when query key is email
180
+ if (serviceGroup === 'preauth' || serviceGroup === 'signin') {
181
+ const queryKey = Object.keys(query)[0];
182
+
183
+ formattedQuery = {};
184
+
185
+ if (queryKey === 'email' && query.email) {
186
+ formattedQuery.emailhash = sha256(query.email.toLowerCase()).toString();
187
+ } else {
188
+ formattedQuery[queryKey] = query[queryKey];
189
+ }
190
+ }
191
+
192
+ return this._fetchNewServiceHostmap({
193
+ from,
194
+ token,
195
+ query: formattedQuery,
196
+ forceRefresh,
197
+ })
198
+ .then((serviceHostMap: ServiceHostmap) => {
199
+ catalog.updateServiceGroups(serviceGroup, serviceHostMap);
200
+ this.updateCredentialsConfig();
201
+ catalog.status[serviceGroup].collecting = false;
202
+ })
203
+ .catch((error) => {
204
+ catalog.status[serviceGroup].collecting = false;
205
+
206
+ return Promise.reject(error);
207
+ });
208
+ },
209
+
210
+ /**
211
+ * User validation parameter transfer object for {@link validateUser}.
212
+ * @param {object} ValidateUserPTO
213
+ * @property {string} ValidateUserPTO.email - The email of the user.
214
+ * @property {string} [ValidateUserPTO.reqId] - The activation requester.
215
+ * @property {object} [ValidateUserPTO.activationOptions] - Extra options to pass when sending the activation
216
+ * @property {object} [ValidateUserPTO.preloginUserId] - The prelogin user id to set when sending the activation.
217
+ */
218
+
219
+ /**
220
+ * User validation return transfer object for {@link validateUser}.
221
+ * @param {object} ValidateUserRTO
222
+ * @property {boolean} ValidateUserRTO.activated - If the user is activated.
223
+ * @property {boolean} ValidateUserRTO.exists - If the user exists.
224
+ * @property {string} ValidateUserRTO.details - A descriptive status message.
225
+ * @property {object} ValidateUserRTO.user - **License** service user object.
226
+ */
227
+
228
+ /**
229
+ * Validate if a user is activated and update the service catalogs as needed
230
+ * based on the user's activation status.
231
+ *
232
+ * @param {ValidateUserPTO} - The parameter transfer object.
233
+ * @returns {ValidateUserRTO} - The return transfer object.
234
+ */
235
+ validateUser({
236
+ email,
237
+ reqId = 'WEBCLIENT',
238
+ forceRefresh = false,
239
+ activationOptions = {},
240
+ preloginUserId,
241
+ }) {
242
+ this.logger.info('services: validating a user');
243
+
244
+ // Validate that an email parameter key was provided.
245
+ if (!email) {
246
+ return Promise.reject(new Error('`email` is required'));
247
+ }
248
+
249
+ // Destructure the credentials object.
250
+ const {canAuthorize} = this.webex.credentials;
251
+
252
+ // Validate that the user is already authorized.
253
+ if (canAuthorize) {
254
+ return this.updateServices({forceRefresh})
255
+ .then(() => this.webex.credentials.getUserToken())
256
+ .then((token) =>
257
+ this.sendUserActivation({
258
+ email,
259
+ reqId,
260
+ token: token.toString(),
261
+ activationOptions,
262
+ preloginUserId,
263
+ })
264
+ )
265
+ .then((userObj) => ({
266
+ activated: true,
267
+ exists: true,
268
+ details: 'user is authorized via a user token',
269
+ user: userObj,
270
+ }));
271
+ }
272
+
273
+ // Destructure the client authorization details.
274
+ /* eslint-disable camelcase */
275
+ const {client_id, client_secret} = this.webex.credentials.config;
276
+
277
+ // Validate that client authentication details exist.
278
+ if (!client_id || !client_secret) {
279
+ return Promise.reject(new Error('client authentication details are not available'));
280
+ }
281
+ /* eslint-enable camelcase */
282
+
283
+ // Declare a class-memeber-scoped token for usage within the promise chain.
284
+ let token;
285
+
286
+ // Begin client authentication user validation.
287
+ return (
288
+ this.collectPreauthCatalog({email})
289
+ .then(() => {
290
+ // Retrieve the service url from the updated catalog. This is required
291
+ // since `WebexCore` is usually not fully initialized at the time this
292
+ // request completes.
293
+ const idbrokerService = this.get('idbroker');
294
+
295
+ // Collect the client auth token.
296
+ return this.webex.credentials.getClientToken({
297
+ uri: `${idbrokerService}idb/oauth2/v1/access_token`,
298
+ scope: 'webexsquare:admin webexsquare:get_conversation Identity:SCIM',
299
+ });
300
+ })
301
+ .then((tokenObj) => {
302
+ // Generate the token string.
303
+ token = tokenObj.toString();
304
+
305
+ // Collect the signin catalog using the client auth information.
306
+ return this.collectSigninCatalog({email, token, forceRefresh});
307
+ })
308
+ // Validate if collecting the signin catalog failed and populate the RTO
309
+ // with the appropriate content.
310
+ .catch((error) => ({
311
+ exists: error.name !== 'NotFound',
312
+ activated: false,
313
+ details:
314
+ error.name !== 'NotFound'
315
+ ? 'user exists but is not activated'
316
+ : 'user does not exist and is not activated',
317
+ }))
318
+ // Validate if the previous promise resolved with an RTO and populate the
319
+ // new RTO accordingly.
320
+ .then((rto) =>
321
+ Promise.all([
322
+ rto || {
323
+ activated: true,
324
+ exists: true,
325
+ details: 'user exists and is activated',
326
+ },
327
+ this.sendUserActivation({
328
+ email,
329
+ reqId,
330
+ token,
331
+ activationOptions,
332
+ preloginUserId,
333
+ }),
334
+ ])
335
+ )
336
+ .then(([rto, user]) => ({...rto, user}))
337
+ .catch((error) => {
338
+ const response = {
339
+ statusCode: error.statusCode,
340
+ responseText: error.body && error.body.message,
341
+ body: error.body,
342
+ };
343
+
344
+ return Promise.reject(response);
345
+ })
346
+ );
347
+ },
348
+
349
+ /**
350
+ * Get user meeting preferences (preferred webex site).
351
+ *
352
+ * @returns {object} - User Information including user preferrences .
353
+ */
354
+ getMeetingPreferences() {
355
+ return this.request({
356
+ method: 'GET',
357
+ service: 'hydra',
358
+ resource: 'meetingPreferences',
359
+ })
360
+ .then((res) => {
361
+ this.logger.info('services: received user region info');
362
+
363
+ return res.body;
364
+ })
365
+ .catch((err) => {
366
+ this.logger.info('services: was not able to fetch user login information', err);
367
+ // resolve successfully even if request failed
368
+ });
369
+ },
370
+
371
+ /**
372
+ * Fetches client region info such as countryCode and timezone.
373
+ *
374
+ * @returns {object} - The region info object.
375
+ */
376
+ fetchClientRegionInfo() {
377
+ const {services} = this.webex.config;
378
+
379
+ return this.request({
380
+ uri: services.discovery.sqdiscovery,
381
+ addAuthHeader: false,
382
+ headers: {
383
+ 'spark-user-agent': null,
384
+ },
385
+ timeout: 5000,
386
+ })
387
+ .then((res) => {
388
+ this.logger.info('services: received user region info');
389
+
390
+ return res.body;
391
+ })
392
+ .catch((err) => {
393
+ this.logger.info('services: was not able to get user region info', err);
394
+ // resolve successfully even if request failed
395
+ });
396
+ },
397
+
398
+ /**
399
+ * User activation parameter transfer object for {@link sendUserActivation}.
400
+ * @typedef {object} SendUserActivationPTO
401
+ * @property {string} SendUserActivationPTO.email - The email of the user.
402
+ * @property {string} SendUserActivationPTO.reqId - The activation requester.
403
+ * @property {string} SendUserActivationPTO.token - The client auth token.
404
+ * @property {object} SendUserActivationPTO.activationOptions - Extra options to pass when sending the activation.
405
+ * @property {object} SendUserActivationPTO.preloginUserId - The prelogin user id to set when sending the activation.
406
+ */
407
+
408
+ /**
409
+ * Send a request to activate a user using a client token.
410
+ *
411
+ * @param {SendUserActivationPTO} - The Parameter transfer object.
412
+ * @returns {LicenseDTO} - The DTO returned from the **License** service.
413
+ */
414
+ sendUserActivation({email, reqId, token, activationOptions, preloginUserId}) {
415
+ this.logger.info('services: sending user activation request');
416
+ let countryCode;
417
+ let timezone;
418
+
419
+ // try to fetch client region info first
420
+ return (
421
+ this.fetchClientRegionInfo()
422
+ .then((clientRegionInfo) => {
423
+ if (clientRegionInfo) {
424
+ ({countryCode, timezone} = clientRegionInfo);
425
+ }
426
+
427
+ // Send the user activation request to the **License** service.
428
+ return this.request({
429
+ service: 'license',
430
+ resource: 'users/activations',
431
+ method: 'POST',
432
+ headers: {
433
+ accept: 'application/json',
434
+ authorization: token,
435
+ 'x-prelogin-userid': preloginUserId,
436
+ },
437
+ body: {
438
+ email,
439
+ reqId,
440
+ countryCode,
441
+ timeZone: timezone,
442
+ ...activationOptions,
443
+ },
444
+ shouldRefreshAccessToken: false,
445
+ });
446
+ })
447
+ // On success, return the **License** user object.
448
+ .then(({body}) => body)
449
+ // On failure, reject with error from **License**.
450
+ .catch((error) => Promise.reject(error))
451
+ );
452
+ },
453
+
454
+ /**
455
+ * Updates a given service group i.e. preauth, signin, postauth with a new hostmap.
456
+ * @param {ServiceGroup} serviceGroup - preauth, signin, postauth
457
+ * @param {ServiceHostmap} hostMap - The new hostmap to update the service group with.
458
+ * @returns {Promise<void>}
459
+ */
460
+ updateCatalog(serviceGroup: ServiceGroup, hostMap: ServiceHostmap): Promise<void> {
461
+ const catalog = this._getCatalog();
462
+
463
+ const serviceHostMap = this._formatReceivedHostmap(hostMap);
464
+
465
+ return catalog.updateServiceGroups(serviceGroup, serviceHostMap);
466
+ },
467
+
468
+ /**
469
+ * simplified method to update the preauth catalog via email
470
+ *
471
+ * @param {object} query
472
+ * @param {string} query.email - A standard format email.
473
+ * @param {string} query.orgId - The user's OrgId.
474
+ * @param {boolean} forceRefresh - Boolean to bypass u2c cache control header
475
+ * @returns {Promise<void>}
476
+ */
477
+ collectPreauthCatalog(query: QueryOptions, forceRefresh = false) {
478
+ if (!query) {
479
+ return this.updateServices({
480
+ from: 'limited',
481
+ query: {mode: 'DEFAULT_BY_PROXIMITY'},
482
+ forceRefresh,
483
+ });
484
+ }
485
+
486
+ return this.updateServices({from: 'limited', query, forceRefresh});
487
+ },
488
+
489
+ /**
490
+ * simplified method to update the signin catalog via email and token
491
+ * @param {object} param
492
+ * @param {string} param.email - must be a standard-format email
493
+ * @param {string} param.token - must be a client token
494
+ * @returns {Promise<void>}
495
+ */
496
+ collectSigninCatalog(
497
+ {email, token, forceRefresh} = {} as {email: string; token: string; forceRefresh: boolean}
498
+ ): Promise<void> {
499
+ if (!email) {
500
+ return Promise.reject(new Error('`email` is required'));
501
+ }
502
+ if (!token) {
503
+ return Promise.reject(new Error('`token` is required'));
504
+ }
505
+
506
+ return this.updateServices({
507
+ from: 'signin',
508
+ query: {email},
509
+ token,
510
+ forceRefresh,
511
+ });
512
+ },
513
+
514
+ /**
515
+ * Updates credentials config to utilize u2c catalog
516
+ * urls.
517
+ * @returns {void}
518
+ */
519
+ updateCredentialsConfig(): void {
520
+ const idbrokerUrl = this.get('idbroker');
521
+ const identityUrl = this.get('identity');
522
+
523
+ if (idbrokerUrl && identityUrl) {
524
+ const {authorizationString, authorizeUrl} = this.webex.config.credentials;
525
+
526
+ // This must be set outside of the setConfig method used to assign the
527
+ // idbroker and identity url values.
528
+ this.webex.config.credentials.authorizeUrl = authorizationString
529
+ ? authorizeUrl
530
+ : `${idbrokerUrl.replace(trailingSlashes, '')}/idb/oauth2/v1/authorize`;
531
+
532
+ this.webex.setConfig({
533
+ credentials: {
534
+ idbroker: {
535
+ url: idbrokerUrl.replace(trailingSlashes, ''), // remove trailing slash
536
+ },
537
+ identity: {
538
+ url: identityUrl.replace(trailingSlashes, ''), // remove trailing slash
539
+ },
540
+ },
541
+ });
542
+ }
543
+ },
544
+
545
+ /**
546
+ * Wait until the service catalog is available,
547
+ * or reject afte ra timeout of 60 seconds.
548
+ * @param {ServiceGroup} serviceGroup
549
+ * @param {number} [timeout] - in seconds
550
+ * @returns {Promise<void>}
551
+ */
552
+ waitForCatalog(serviceGroup: ServiceGroup, timeout: number): Promise<void> {
553
+ const catalog = this._getCatalog();
554
+ const {supertoken} = this.webex.credentials;
555
+
556
+ if (
557
+ serviceGroup === 'postauth' &&
558
+ supertoken &&
559
+ supertoken.access_token &&
560
+ !catalog.status.postauth.collecting &&
561
+ !catalog.status.postauth.ready
562
+ ) {
563
+ if (!catalog.status.preauth.ready) {
564
+ return this.initServiceCatalogs();
565
+ }
566
+
567
+ return this.updateServices();
568
+ }
569
+
570
+ return catalog.waitForCatalog(serviceGroup, timeout);
571
+ },
572
+
573
+ /**
574
+ * Service waiting parameter transfer object for {@link waitForService}.
575
+ *
576
+ * @typedef {object} WaitForServicePTO
577
+ * @property {string} [WaitForServicePTO.name] - The service name.
578
+ * @property {string} [WaitForServicePTO.url] - The service url.
579
+ * @property {string} [WaitForServicePTO.timeout] - wait duration in seconds.
580
+ */
581
+
582
+ /**
583
+ * Wait until the service has been ammended to any service catalog. This
584
+ * method prioritizes the service name over the service url when searching.
585
+ *
586
+ * @param {WaitForServicePTO} - The parameter transfer object.
587
+ * @returns {Promise<string>} - Resolves to the priority host of a service.
588
+ */
589
+ waitForService({
590
+ name,
591
+ timeout = 5,
592
+ url,
593
+ }: {
594
+ name: string;
595
+ timeout: number;
596
+ url: string;
597
+ }): Promise<string> {
598
+ const {services} = this.webex.config;
599
+
600
+ // Save memory by grabbing the catalog after there isn't a priortyURL
601
+ const catalog = this._getCatalog();
602
+
603
+ const fetchFromServiceUrl = services.servicesNotNeedValidation.find(
604
+ (service) => service === name
605
+ );
606
+
607
+ if (fetchFromServiceUrl) {
608
+ const clusterId = this._activeServices[name];
609
+
610
+ return Promise.resolve(this.get(clusterId));
611
+ }
612
+
613
+ const priorityUrl = this.get(name);
614
+ const priorityUrlObj = this.getServiceFromUrl(url);
615
+
616
+ if (priorityUrl || priorityUrlObj) {
617
+ return Promise.resolve(priorityUrl || priorityUrlObj.priorityUrl);
618
+ }
619
+
620
+ if (catalog.isReady) {
621
+ if (url) {
622
+ return Promise.resolve(url);
623
+ }
624
+
625
+ this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, {
626
+ fields: {service_name: name},
627
+ });
628
+
629
+ return Promise.reject(
630
+ new Error(`services: service '${name}' was not found in any of the catalogs`)
631
+ );
632
+ }
633
+
634
+ return new Promise((resolve, reject) => {
635
+ const groupsToCheck = ['preauth', 'signin', 'postauth'];
636
+ const checkCatalog = (catalogGroup) =>
637
+ catalog
638
+ .waitForCatalog(catalogGroup, timeout)
639
+ .then(() => {
640
+ const scopedPriorityUrl = this.get(name);
641
+ const scopedPrioriryUrlObj = this.getServiceFromUrl(url);
642
+
643
+ if (scopedPriorityUrl || scopedPrioriryUrlObj) {
644
+ resolve(scopedPriorityUrl || scopedPrioriryUrlObj.priorityUrl);
645
+ }
646
+ })
647
+ .catch(() => undefined);
648
+
649
+ Promise.all(groupsToCheck.map((group) => checkCatalog(group))).then(() => {
650
+ this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, {
651
+ fields: {service_name: name},
652
+ });
653
+ reject(new Error(`services: service '${name}' was not found after waiting`));
654
+ });
655
+ });
656
+ },
657
+
658
+ /**
659
+ * Looks up the hostname in the host catalog
660
+ * and replaces it with the first host if it finds it
661
+ * @param {string} uri
662
+ * @returns {string} uri with the host replaced
663
+ */
664
+ replaceHostFromHostmap(uri: string): string {
665
+ try {
666
+ return this.convertUrlToPriorityHostUrl(uri);
667
+ } catch {
668
+ return uri;
669
+ }
670
+ },
671
+
672
+ /**
673
+ * Formats a host map entry for use in service catalog.
674
+ *
675
+ * @param {Object} entry - The host map entry to format.
676
+ * @param {string} entry.serviceName - i.e. conversation, identity, etc.
677
+ * @param {string} entry.id - The unique identifier for the service, usually clusterId.
678
+ * @param {Array<IServiceDetail>} entry.serviceUrls - The group to which the service belongs.
679
+ * @returns {Object} - The formatted host map entry.
680
+ */
681
+ _formatHostMapEntry({id, serviceName, serviceUrls}) {
682
+ const formattedServiceUrls = serviceUrls.map((serviceUrl) => ({
683
+ host: new URL(serviceUrl.baseUrl).host,
684
+ ...serviceUrl,
685
+ }));
686
+
687
+ return {
688
+ id,
689
+ serviceName,
690
+ serviceUrls: formattedServiceUrls,
691
+ };
692
+ },
693
+
694
+ /**
695
+ * @private
696
+ * Organize a received hostmap from a service
697
+ * @param {ServiceHostmap} serviceHostmap
698
+ * catalog endpoint.
699
+ * @returns {Array<Service>}
700
+ */
701
+ _formatReceivedHostmap({services, activeServices}) {
702
+ const formattedHostmap = services.map((service) => this._formatHostMapEntry(service));
703
+ this._updateActiveServices(activeServices);
704
+ this._updateServices(services);
705
+
706
+ return formattedHostmap;
707
+ },
708
+
709
+ /**
710
+ * Get the clusterId associated with a URL string.
711
+ * @param {string} url
712
+ * @returns {string | undefined} - Cluster ID of url provided
713
+ */
714
+ getClusterId(url: string): string | undefined {
715
+ const catalog = this._getCatalog();
716
+
717
+ return catalog.findClusterId(url);
718
+ },
719
+
720
+ /**
721
+ * Get a service value from a provided clusterId. This method will
722
+ * return an object containing both the name and url of a found service.
723
+ * @param {object} params
724
+ * @param {string} params.clusterId - clusterId of found service
725
+ * @param {ServiceGroup} [params.serviceGroup] - specify service group
726
+ * @returns {object} service
727
+ * @returns {string} service.name
728
+ * @returns {string} service.url
729
+ */
730
+ getServiceFromClusterId(params: {
731
+ clusterId: string;
732
+ serviceGroup?: ServiceGroup;
733
+ }): {name: string; url: string} | undefined {
734
+ const catalog = this._getCatalog();
735
+
736
+ return catalog.findServiceFromClusterId(params);
737
+ },
738
+
739
+ /**
740
+ * @param {String} cluster the cluster containing the id
741
+ * @param {UUID} [id] the id of the conversation.
742
+ * If empty, just return the base URL.
743
+ * @returns {String} url of the service
744
+ */
745
+ getServiceUrlFromClusterId({cluster = 'us'}: {cluster?: string} = {}): string {
746
+ let clusterId = cluster === 'us' ? DEFAULT_CLUSTER_IDENTIFIER : cluster;
747
+
748
+ // Determine if cluster has service name (non-US clusters from hydra do not)
749
+ if (clusterId.split(':').length < 4) {
750
+ // Add Service to cluster identifier
751
+ clusterId = `${cluster}:${CLUSTER_SERVICE}`;
752
+ }
753
+
754
+ const {url} = this.getServiceFromClusterId({clusterId}) || {};
755
+
756
+ if (!url) {
757
+ throw Error(`Could not find service for cluster [${cluster}]`);
758
+ }
759
+
760
+ return url;
761
+ },
762
+
763
+ /**
764
+ * Get a service object from a service url if the service url exists in the
765
+ * catalog.
766
+ *
767
+ * @param {string} url - The url to be validated.
768
+ * @returns {object} - Service object.
769
+ * @returns {object.name} - The name of the service found.
770
+ * @returns {object.priorityUrl} - The default url of the found service.
771
+ * @returns {object.defaultUrl} - The default url of the found service.
772
+ */
773
+ getServiceFromUrl(url = ''): {name: string; priorityUrl: string; defaultUrl: string} | undefined {
774
+ const service = this._getCatalog().findServiceDetailFromUrl(url);
775
+
776
+ if (!service) {
777
+ return undefined;
778
+ }
779
+
780
+ const priorityUrl = service.get();
781
+ const defaultUrl = new URL(
782
+ service.serviceUrls.find((serviceUrl) => url.startsWith(serviceUrl.baseUrl)).baseUrl
783
+ ).href;
784
+
785
+ return {
786
+ name: service.serviceName,
787
+ priorityUrl,
788
+ defaultUrl,
789
+ };
790
+ },
791
+
792
+ /**
793
+ * Determine if a provided url is in the catalog's allowed domains.
794
+ *
795
+ * @param {string} url - The url to match allowed domains against.
796
+ * @returns {boolean} - True if the url provided is allowed.
797
+ */
798
+ isAllowedDomainUrl(url: string): boolean {
799
+ const catalog = this._getCatalog();
800
+
801
+ return !!catalog.findAllowedDomain(url);
802
+ },
803
+
804
+ /**
805
+ * Converts the host portion of the url from default host
806
+ * to a priority host
807
+ *
808
+ * @param {string} url a service url that contains a default host
809
+ * @returns {string} a service url that contains the top priority host.
810
+ * @throws if url isn't a service url
811
+ */
812
+ convertUrlToPriorityHostUrl(url = '' as string): string {
813
+ const data = this.getServiceFromUrl(url);
814
+
815
+ if (!data) {
816
+ throw Error(`No service associated with url: [${url}]`);
817
+ }
818
+
819
+ return url.replace(data.defaultUrl, data.priorityUrl);
820
+ },
821
+
822
+ /**
823
+ * @private
824
+ * Simplified method wrapper for sending a request to get
825
+ * an updated service hostmap.
826
+ * @param {object} [param]
827
+ * @param {string} [param.from] - This accepts `limited` or `signin`
828
+ * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values
829
+ * @param {string} [param.query.email] - must be a standard-format email
830
+ * @param {string} [param.query.orgId] - must be an organization id
831
+ * @param {string} [param.query.userId] - must be a user id
832
+ * @param {string} [param.token] - used for signin catalog
833
+ * @returns {Promise<object>}
834
+ */
835
+ _fetchNewServiceHostmap(
836
+ {from, query, token, forceRefresh} = {} as {
837
+ from: string;
838
+ query: QueryOptions;
839
+ token: string;
840
+ forceRefresh: boolean;
841
+ }
842
+ ): Promise<object> {
843
+ const service = 'u2c';
844
+ const resource = from ? `/${from}/catalog` : '/catalog';
845
+ const qs = {...(query || {}), format: 'U2CV2'};
846
+
847
+ if (forceRefresh) {
848
+ qs.timestamp = new Date().getTime();
849
+ }
850
+
851
+ const requestObject = {
852
+ method: 'GET',
853
+ service,
854
+ resource,
855
+ qs,
856
+ headers: {},
857
+ };
858
+
859
+ if (token) {
860
+ requestObject.headers = {authorization: token};
861
+ }
862
+
863
+ return this.webex.internal.newMetrics.callDiagnosticLatencies
864
+ .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time')
865
+ .then(({body}) => this._formatReceivedHostmap(body));
866
+ },
867
+
868
+ /**
869
+ * Initialize the discovery services and the whitelisted services.
870
+ *
871
+ * @returns {void}
872
+ */
873
+ initConfig(): void {
874
+ // Get the catalog and destructure the services config.
875
+ const catalog = this._getCatalog();
876
+ const {services, fedramp} = this.webex.config;
877
+
878
+ // Validate that the services configuration exists.
879
+ if (services) {
880
+ if (fedramp) {
881
+ services.discovery = fedRampServices;
882
+ }
883
+ // Check for discovery services.
884
+ if (services.discovery) {
885
+ // Format the discovery configuration into an injectable array.
886
+ const formattedDiscoveryServices = Object.keys(services.discovery).map((key) =>
887
+ this._formatHostMapEntry({
888
+ id: key,
889
+ serviceName: key,
890
+ serviceUrls: [{baseUrl: services.discovery[key], priority: 1}],
891
+ })
892
+ );
893
+
894
+ // Inject formatted discovery services into services catalog.
895
+ catalog.updateServiceGroups('discovery', formattedDiscoveryServices);
896
+ }
897
+
898
+ if (services.override) {
899
+ // Format the override configuration into an injectable array.
900
+ const formattedOverrideServices = Object.keys(services.override).map((key) =>
901
+ this._formatHostMapEntry({
902
+ id: key,
903
+ serviceName: key,
904
+ serviceUrls: [{baseUrl: services.override[key], priority: 1}],
905
+ })
906
+ );
907
+
908
+ // Inject formatted override services into services catalog.
909
+ catalog.updateServiceGroups('override', formattedOverrideServices);
910
+ }
911
+
912
+ // if not fedramp, append on the commercialAllowedDomains
913
+ if (!fedramp) {
914
+ services.allowedDomains = union(services.allowedDomains, COMMERCIAL_ALLOWED_DOMAINS);
915
+ }
916
+
917
+ // Check for allowed host domains.
918
+ if (services.allowedDomains) {
919
+ // Store the allowed domains as a property of the catalog.
920
+ catalog.setAllowedDomains(services.allowedDomains);
921
+ }
922
+
923
+ // Set `validateDomains` property to match configuration
924
+ this.validateDomains = services.validateDomains;
925
+ }
926
+ },
927
+
928
+ /**
929
+ * Make the initial requests to collect the root catalogs.
930
+ *
931
+ * @returns {Promise<void, Error>} - Errors if the token is unavailable.
932
+ */
933
+ initServiceCatalogs(): Promise<void> {
934
+ this.logger.info('services: initializing initial service catalogs');
935
+
936
+ // Destructure the credentials plugin.
937
+ const {credentials} = this.webex;
938
+
939
+ // Init a promise chain. Must be done as a Promise.resolve() to allow
940
+ // credentials#getOrgId() to properly throw.
941
+ return (
942
+ Promise.resolve()
943
+ // Get the user's OrgId.
944
+ .then(() => credentials.getOrgId())
945
+ // Begin collecting the preauth/limited catalog.
946
+ .then((orgId) => this.collectPreauthCatalog({orgId}))
947
+ .then(() => {
948
+ // Validate if the token is authorized.
949
+ if (credentials.canAuthorize) {
950
+ // Attempt to collect the postauth catalog.
951
+ return this.updateServices().catch(() => {
952
+ this.initFailed = true;
953
+ this.logger.warn('services: cannot retrieve postauth catalog');
954
+ });
955
+ }
956
+
957
+ // Return a resolved promise for consistent return value.
958
+ return Promise.resolve();
959
+ })
960
+ );
961
+ },
962
+
963
+ /**
964
+ * Initializer
965
+ *
966
+ * @instance
967
+ * @memberof Services
968
+ * @returns {Services}
969
+ */
970
+ initialize(): typeof Services {
971
+ const catalog = new ServiceCatalog();
972
+ this._catalogs.set(this.webex, catalog);
973
+
974
+ // Listen for configuration changes once.
975
+ this.listenToOnce(this.webex, 'change:config', () => {
976
+ this.initConfig();
977
+ });
978
+
979
+ // wait for webex instance to be ready before attempting
980
+ // to update the service catalogs
981
+ this.listenToOnce(this.webex, 'ready', () => {
982
+ const {supertoken} = this.webex.credentials;
983
+ // Validate if the supertoken exists.
984
+ if (supertoken && supertoken.access_token) {
985
+ this.initServiceCatalogs()
986
+ .then(() => {
987
+ catalog.isReady = true;
988
+ })
989
+ .catch((error) => {
990
+ this.initFailed = true;
991
+ this.logger.error(
992
+ `services: failed to init initial services when credentials available, ${error?.message}`
993
+ );
994
+ });
995
+ } else {
996
+ const {email} = this.webex.config;
997
+
998
+ this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
999
+ this.initFailed = true;
1000
+ this.logger.error(
1001
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1002
+ );
1003
+ });
1004
+ }
1005
+ });
1006
+ },
1007
+ });
1008
+ /* eslint-enable no-underscore-dangle */
1009
+
1010
+ export default Services;