@openfin/cloud-interop-core-api 0.0.1-alpha.6d7b62b → 0.0.1-alpha.6d9e190

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.
@@ -1,8 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  var buffer = require('buffer');
4
+ require('fs');
5
+ require('path');
4
6
  var mqtt = require('mqtt');
5
7
 
8
+ var u=n=>{let e=n.replaceAll("-","+").replaceAll("_","/");return e.padEnd(e.length+(4-e.length%4)%4,"=")};
9
+
6
10
  class CloudInteropAPIError extends Error {
7
11
  code;
8
12
  constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
@@ -46,6 +50,8 @@ class EventController {
46
50
  }
47
51
  }
48
52
 
53
+ const isErrorIntentResult = (result) => 'error' in result;
54
+
49
55
  const APP_ID_DELIM = '::';
50
56
  const getRequestHeaders = (connectionParameters) => {
51
57
  const headers = {};
@@ -72,13 +78,9 @@ const getRequestHeaders = (connectionParameters) => {
72
78
  * @param source
73
79
  * @returns
74
80
  */
75
- const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map((intent) => ({
81
+ const encodeAppIntents = (appIntents, source) => appIntents.map((intent) => ({
76
82
  ...intent,
77
- apps: intent.apps.map((app) => {
78
- const id = encodeURIComponent(app.appId);
79
- const sId = encodeURIComponent(sourceId);
80
- return { ...app, appId: `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}` };
81
- }),
83
+ apps: intent.apps.map((app) => ({ ...app, appId: encodeAppId(app.appId, source) })),
82
84
  }));
83
85
  /**
84
86
  * Decodes all app intents by URI decoding the parts previously encoded by `encodeAppIntents`
@@ -87,19 +89,35 @@ const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map
87
89
  */
88
90
  const decodeAppIntents = (appIntents) => appIntents.map((intent) => ({
89
91
  ...intent,
90
- apps: intent.apps.map((app) => {
91
- const [encodedAppId, encodedSourceId, sessionId] = app.appId.split(APP_ID_DELIM);
92
- const id = decodeURIComponent(encodedAppId);
93
- const sourceId = decodeURIComponent(encodedSourceId);
94
- return { ...app, appId: `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}` };
95
- }),
92
+ apps: intent.apps.map((app) => ({ ...app, appId: decodeAppId(app.appId) })),
96
93
  }));
94
+ const encodeAppId = (appIdString, { sessionId, sourceId }) => {
95
+ const id = encodeURIComponent(appIdString);
96
+ const sId = encodeURIComponent(sourceId);
97
+ return `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}`;
98
+ };
99
+ const decodeAppId = (appId) => {
100
+ const [encodedAppId, encodedSourceId, sessionId] = appId.split(APP_ID_DELIM);
101
+ const id = decodeURIComponent(encodedAppId);
102
+ const sourceId = decodeURIComponent(encodedSourceId);
103
+ return `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}`;
104
+ };
97
105
  /**
98
- * Decodes the app id to extract the sessionId, returns '' if not able to parse
99
- * @param app
100
- * @returns
106
+ * Decodes the AppIdentifier to extract the appId, sourceId, and sessionId.
107
+ * @returns an object with:
108
+ * - appId: The appId, or the original appId if unable to parse.
109
+ * - sourceId: The sourceId, or an '' if unable to parse.
110
+ * - sessionId: The sessionId, or an '' if unable to parse.
101
111
  */
102
- const parseSessionId = (appId) => (typeof appId === 'string' ? appId : appId.appId).split(APP_ID_DELIM)?.[2]?.trim() ?? '';
112
+ const parseCloudAppId = (appId = '') => {
113
+ const originalAppString = typeof appId === 'string' ? appId : (appId.appId ?? '');
114
+ const parts = originalAppString.split(APP_ID_DELIM);
115
+ return {
116
+ appId: parts[0]?.trim() ?? originalAppString,
117
+ sourceId: parts[1]?.trim() ?? '',
118
+ sessionId: parts[2]?.trim() ?? '',
119
+ };
120
+ };
103
121
  const getSourceFromSession = (sessionDetails) => ({
104
122
  sessionId: sessionDetails.sessionId,
105
123
  sourceId: sessionDetails.sourceId,
@@ -148,13 +166,18 @@ class IntentController {
148
166
  body: JSON.stringify({ findOptions }),
149
167
  });
150
168
  if (!startResponse.ok) {
151
- throw new Error(startResponse.statusText);
169
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
152
170
  }
153
171
  // TODO: type this response?
154
172
  const json = await startResponse.json();
155
173
  this.#discovery.id = json.discoveryId;
156
174
  this.#discovery.sessionCount = json.sessionCount;
157
175
  this.#discovery.state = 'in-progress';
176
+ if (this.#discovery.sessionCount === 1) {
177
+ // since we have no other connected sessions, we can end discovery immediately
178
+ await this.#endIntentDiscovery(false);
179
+ return;
180
+ }
158
181
  // Listen out for discovery results directly sent to us
159
182
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
160
183
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -165,10 +188,8 @@ class IntentController {
165
188
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
166
189
  }
167
190
  }
168
- async #endIntentDiscovery() {
191
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
169
192
  if (this.#discovery.state !== 'in-progress') {
170
- // TODO: remove debug logs
171
- this.#logger('debug', 'Intent discovery not in progress');
172
193
  return;
173
194
  }
174
195
  if (this.#discoveryTimeout) {
@@ -178,10 +199,12 @@ class IntentController {
178
199
  this.#discovery.state = 'ended';
179
200
  // emit our aggregated events
180
201
  this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
181
- // gracefully end discovery
182
- await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
183
- this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
184
- });
202
+ if (mqttUnsubscribe) {
203
+ // gracefully end discovery
204
+ await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
205
+ this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
206
+ });
207
+ }
185
208
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
186
209
  method: 'DELETE',
187
210
  headers: getRequestHeaders(this.#connectionParams),
@@ -190,7 +213,6 @@ class IntentController {
190
213
  if (!deleteResponse.ok) {
191
214
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
192
215
  }
193
- this.#logger('debug', 'Intent discovery ended');
194
216
  })
195
217
  .catch((error) => {
196
218
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -199,7 +221,7 @@ class IntentController {
199
221
  this.#discovery = newDiscovery();
200
222
  }
201
223
  async raiseIntent({ raiseOptions, appId }) {
202
- const targetSessionId = parseSessionId(appId);
224
+ const targetSessionId = parseCloudAppId(appId).sessionId;
203
225
  if (!targetSessionId) {
204
226
  // TODO: should we add more info here about the format?
205
227
  throw new CloudInteropAPIError(`Invalid AppId specified, must be encoded as a cloud-session app id`, 'ERR_INVALID_TARGET_SESSION_ID');
@@ -234,6 +256,13 @@ class IntentController {
234
256
  return false;
235
257
  }
236
258
  async sendIntentResult(initiatingSessionId, result) {
259
+ if (!isErrorIntentResult(result)) {
260
+ // cloud-encode the source app id to support chained intent actions over cloud
261
+ // https://fdc3.finos.org/docs/2.0/api/spec#resolution-object -> "Use metadata about the resolving app instance to target a further intent"
262
+ const source = getSourceFromSession(this.#sessionDetails);
263
+ const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
264
+ result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
265
+ }
237
266
  const { sessionId } = getSourceFromSession(this.#sessionDetails);
238
267
  const resultResponse = await fetch(`${this.#url}/api/intents/${initiatingSessionId}/result/${sessionId}`, {
239
268
  method: 'POST',
@@ -311,9 +340,8 @@ const BadUserNamePasswordError = 134;
311
340
  /**
312
341
  * Represents a single connection to a Cloud Interop service
313
342
  *
314
- * @export
315
- * @class CloudInteropAPI
316
- * @implements {Client}
343
+ * @public
344
+ * @class
317
345
  */
318
346
  class CloudInteropAPI {
319
347
  #cloudInteropSettings;
@@ -329,6 +357,7 @@ class CloudInteropAPI {
329
357
  #attemptingToReconnect = false;
330
358
  #events = new EventController();
331
359
  #intents;
360
+ #sessionTimer;
332
361
  constructor(cloudInteropSettings) {
333
362
  this.#cloudInteropSettings = cloudInteropSettings;
334
363
  }
@@ -341,11 +370,11 @@ class CloudInteropAPI {
341
370
  /**
342
371
  * Connects and creates a session on the Cloud Interop service
343
372
  *
344
- * @param {ConnectParameters} parameters - The parameters to use to connect
345
- * @return {*} {Promise<void>}
373
+ * @param parameters - The parameters to use to connect
374
+ * @returns Promise that resolves when connection is established
346
375
  * @memberof CloudInteropAPI
347
- * @throws {CloudInteropAPIError} - If an error occurs during connection
348
- * @throws {AuthorizationError} - If the connection is unauthorized
376
+ * @throws CloudInteropAPIError - If an error occurs during connection
377
+ * @throws AuthorizationError - If the connection is unauthorized
349
378
  */
350
379
  async connect(parameters) {
351
380
  this.#validateConnectParams(parameters);
@@ -369,6 +398,11 @@ class CloudInteropAPI {
369
398
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
370
399
  }
371
400
  this.#sessionDetails = (await createSessionResponse.json());
401
+ // If local session expiry handling is enabled, start the session timer
402
+ if (this.#sessionDetails.localSessionExpiryHandling) {
403
+ this.#logger('debug', `Local session expiry handling is enabled`);
404
+ this.#startSessionTimer();
405
+ }
372
406
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
373
407
  const clientOptions = {
374
408
  keepalive: this.#keepAliveIntervalSeconds,
@@ -398,9 +432,7 @@ class CloudInteropAPI {
398
432
  if (error instanceof mqtt.ErrorWithReasonCode) {
399
433
  switch (error.code) {
400
434
  case BadUserNamePasswordError: {
401
- await this.#disconnect(false);
402
- this.#logger('warn', `Session expired`);
403
- this.#events.emitEvent('session-expired');
435
+ this.#handleSessionExpiry();
404
436
  return;
405
437
  }
406
438
  default: {
@@ -454,9 +486,9 @@ class CloudInteropAPI {
454
486
  /**
455
487
  * Disconnects from the Cloud Interop service
456
488
  *
457
- * @return {*} {Promise<void>}
489
+ * @returns Promise that resolves when disconnected
458
490
  * @memberof CloudInteropAPI
459
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
491
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
460
492
  */
461
493
  async disconnect() {
462
494
  await this.#disconnect(true);
@@ -464,9 +496,9 @@ class CloudInteropAPI {
464
496
  /**
465
497
  * Publishes a new context for the given context group to the other connected sessions
466
498
  *
467
- * @param {string} contextGroup - The context group to publish to
468
- * @param {object} context - The context to publish
469
- * @return {*} {Promise<void>}
499
+ * @param contextGroup - The context group to publish to
500
+ * @param context - The context to publish
501
+ * @returns Promise that resolves when context is published
470
502
  * @memberof CloudInteropAPI
471
503
  */
472
504
  async setContext(contextGroup, context) {
@@ -493,9 +525,9 @@ class CloudInteropAPI {
493
525
  /**
494
526
  * Starts an intent discovery operation
495
527
  *
496
- * @return {*} {Promise<void>}
528
+ * @returns Promise that resolves when intent discovery is started
497
529
  * @memberof CloudInteropAPI
498
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
530
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
499
531
  */
500
532
  async startIntentDiscovery(options) {
501
533
  this.#throwIfNotConnected();
@@ -514,7 +546,10 @@ class CloudInteropAPI {
514
546
  return this.#intents?.sendIntentResult(initiatingSessionId, result);
515
547
  }
516
548
  parseSessionId(appId) {
517
- return parseSessionId(appId);
549
+ return parseCloudAppId(appId).sessionId;
550
+ }
551
+ parseAppId(appId) {
552
+ return parseCloudAppId(appId).appId;
518
553
  }
519
554
  addEventListener(type, callback) {
520
555
  this.#events.addEventListener(type, callback);
@@ -526,10 +561,18 @@ class CloudInteropAPI {
526
561
  this.#events.once(type, callback);
527
562
  }
528
563
  async #disconnect(fireDisconnectedEvent) {
529
- if (!this.#sessionDetails || !this.#connectionParams) {
564
+ if (!this.#sessionDetails) {
530
565
  return;
531
566
  }
532
567
  try {
568
+ if (!this.#connectionParams) {
569
+ throw new Error('Connect parameters must be provided');
570
+ }
571
+ // Cancel session timer if it's running
572
+ if (this.#sessionTimer) {
573
+ clearTimeout(this.#sessionTimer);
574
+ this.#sessionTimer = undefined;
575
+ }
533
576
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
534
577
  method: 'DELETE',
535
578
  headers: getRequestHeaders(this.#connectionParams),
@@ -565,8 +608,13 @@ class CloudInteropAPI {
565
608
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
566
609
  return;
567
610
  }
568
- const { contextGroup, context, source, history } = contextEvent;
569
- this.#events.emitEvent('context', { contextGroup, context, source, history: { ...history, clientReceived: Date.now() } });
611
+ const { context, payload, contextGroup, channelName, source, history } = contextEvent;
612
+ this.#events.emitEvent('context', {
613
+ contextGroup: channelName || contextGroup,
614
+ context: payload || context,
615
+ source,
616
+ history: { ...history, clientReceived: Date.now() },
617
+ });
570
618
  }
571
619
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
572
620
  this.#handleCommandMessage(messageEnvelope);
@@ -623,6 +671,88 @@ class CloudInteropAPI {
623
671
  throw new Error('MQTT client not connected');
624
672
  }
625
673
  }
674
+ /**
675
+ * Extracts the expiration timestamp from a JWT token.
676
+ *
677
+ * @param token - The JWT token string
678
+ * @returns The expiration timestamp in seconds, or null if extraction fails
679
+ */
680
+ #extractExpirationFromJwt(token) {
681
+ try {
682
+ // JWT tokens have three parts separated by dots: header.payload.signature
683
+ // The exp claim is in the payload
684
+ const parts = token.split('.');
685
+ if (parts.length < 2) {
686
+ this.#logger('warn', 'Invalid JWT token format: expected at least 2 parts');
687
+ return null;
688
+ }
689
+ const payload = parts[1];
690
+ // Decode base64url encoded payload
691
+ const decodedBytes = buffer.Buffer.from(u(payload), 'base64');
692
+ const payloadJson = decodedBytes.toString('utf8');
693
+ // Parse JSON to get the exp claim
694
+ const claims = JSON.parse(payloadJson);
695
+ const exp = claims.exp;
696
+ if (exp === undefined || exp === null) {
697
+ this.#logger('warn', "JWT token does not contain 'exp' claim");
698
+ return null;
699
+ }
700
+ if (typeof exp !== 'number') {
701
+ this.#logger('warn', `JWT token 'exp' claim is not a number: ${exp}`);
702
+ return null;
703
+ }
704
+ return exp;
705
+ }
706
+ catch (error) {
707
+ this.#logger('error', `Failed to extract expiration from JWT token: ${error instanceof Error ? error.message : error}`);
708
+ return null;
709
+ }
710
+ }
711
+ /**
712
+ * Start a session timer that will expire at the time specified in the JWT token's exp claim.
713
+ * When the timer fires, it executes the same actions as the BadUserNamePasswordError case.
714
+ */
715
+ #startSessionTimer() {
716
+ if (!this.#sessionDetails?.localSessionExpiryHandling) {
717
+ return;
718
+ }
719
+ const token = this.#sessionDetails.token;
720
+ if (!token) {
721
+ this.#logger('warn', 'Cannot start session timer: token not available');
722
+ return;
723
+ }
724
+ // Extract expiration time from JWT token
725
+ const expTimestamp = this.#extractExpirationFromJwt(token);
726
+ if (expTimestamp === null) {
727
+ this.#logger('warn', 'Cannot start session timer: could not extract expiration from JWT token');
728
+ return;
729
+ }
730
+ const currentTimeSeconds = Math.floor(Date.now() / 1000);
731
+ const delaySeconds = expTimestamp - currentTimeSeconds;
732
+ if (delaySeconds <= 0) {
733
+ this.#logger('warn', 'JWT token has already expired or expires immediately');
734
+ // Execute the same actions as BadUserNamePasswordError case
735
+ this.#handleSessionExpiry();
736
+ return;
737
+ }
738
+ // Clear any existing timer
739
+ if (this.#sessionTimer) {
740
+ clearTimeout(this.#sessionTimer);
741
+ }
742
+ const expirationTimeString = new Date(expTimestamp * 1000).toISOString();
743
+ this.#logger('debug', `Starting session timer to expire in ${delaySeconds} seconds (at ${expirationTimeString})`);
744
+ this.#sessionTimer = setTimeout(async () => {
745
+ this.#handleSessionExpiry();
746
+ }, delaySeconds * 1000);
747
+ }
748
+ /**
749
+ * Handles session expiry by executing the same actions as the BadUserNamePasswordError case.
750
+ */
751
+ async #handleSessionExpiry() {
752
+ await this.#disconnect(false);
753
+ this.#logger('warn', 'Session expired');
754
+ this.#events.emitEvent('session-expired');
755
+ }
626
756
  }
627
757
 
628
758
  exports.AuthorizationError = AuthorizationError;