@openfin/cloud-interop-core-api 0.0.1-alpha.99f112a → 0.0.1-alpha.9a0045a

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.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # @openfin/cloud-interop-core-api
2
2
 
3
- This package contains the core interop library that handles all interactions with the Here™ Cloud Interop Service.
3
+ This package contains the core interop library that handles all interactions with the HERE Cloud Interop Service.
4
4
 
5
5
  It is callable via browser or node applications.
6
6
 
7
7
 
8
8
  ## Authentication
9
9
 
10
- The library supports authentication with the Here™ Cloud Interop Service using the following methods:
10
+ The library supports authentication with the HERE Cloud Interop Service using the following methods:
11
11
  - Basic Authentication
12
12
  - JWT Token Authentication
13
13
  - Default Authentication i.e. Interactive session based authentication using cookies
package/bundle.d.ts CHANGED
@@ -51,7 +51,7 @@ declare const appIntentSchema: z.ZodObject<{
51
51
  name: string;
52
52
  displayName: string;
53
53
  }>;
54
- apps: z.ZodArray<z.ZodObject<z.objectUtil.extendShape<{
54
+ apps: z.ZodArray<z.ZodObject<{
55
55
  description: z.ZodOptional<z.ZodString>;
56
56
  icons: z.ZodOptional<z.ZodArray<z.ZodObject<{
57
57
  size: z.ZodOptional<z.ZodString>;
@@ -88,10 +88,10 @@ declare const appIntentSchema: z.ZodObject<{
88
88
  title: z.ZodOptional<z.ZodString>;
89
89
  tooltip: z.ZodOptional<z.ZodString>;
90
90
  version: z.ZodOptional<z.ZodString>;
91
- }, {
91
+ } & {
92
92
  appId: z.ZodString;
93
93
  instanceId: z.ZodOptional<z.ZodString>;
94
- }>, "strip", z.ZodTypeAny, {
94
+ }, "strip", z.ZodTypeAny, {
95
95
  appId: string;
96
96
  instanceId?: string | undefined;
97
97
  description?: string | undefined;
@@ -245,36 +245,36 @@ export declare class CloudInteropAPI {
245
245
  /**
246
246
  * Connects and creates a session on the Cloud Interop service
247
247
  *
248
- * @param {ConnectParameters} parameters - The parameters to use to connect
249
- * @returns {*} {Promise<void>}
248
+ * @param parameters - The parameters to use to connect
249
+ * @returns Promise that resolves when connection is established
250
250
  * @memberof CloudInteropAPI
251
- * @throws {CloudInteropAPIError} - If an error occurs during connection
252
- * @throws {AuthorizationError} - If the connection is unauthorized
251
+ * @throws CloudInteropAPIError - If an error occurs during connection
252
+ * @throws AuthorizationError - If the connection is unauthorized
253
253
  */
254
254
  connect(parameters: ConnectParameters): Promise<void>;
255
255
  /**
256
256
  * Disconnects from the Cloud Interop service
257
257
  *
258
- * @returns {*} {Promise<void>}
258
+ * @returns Promise that resolves when disconnected
259
259
  * @memberof CloudInteropAPI
260
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
260
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
261
261
  */
262
262
  disconnect(): Promise<void>;
263
263
  /**
264
264
  * Publishes a new context for the given context group to the other connected sessions
265
265
  *
266
- * @param {string} contextGroup - The context group to publish to
267
- * @param {object} context - The context to publish
268
- * @returns {*} {Promise<void>}
266
+ * @param contextGroup - The context group to publish to
267
+ * @param context - The context to publish
268
+ * @returns Promise that resolves when context is published
269
269
  * @memberof CloudInteropAPI
270
270
  */
271
- setContext(contextGroup: string, context: object): Promise<void>;
271
+ setContext(contextGroup: string, context: InferredContext): Promise<void>;
272
272
  /**
273
273
  * Starts an intent discovery operation
274
274
  *
275
- * @returns {*} {Promise<void>}
275
+ * @returns Promise that resolves when intent discovery is started
276
276
  * @memberof CloudInteropAPI
277
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
277
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
278
278
  */
279
279
  startIntentDiscovery(options: StartIntentDiscoveryOptions): Promise<void>;
280
280
  raiseIntent(options: RaiseIntentAPIOptions): Promise<void>;
@@ -418,6 +418,7 @@ export declare type CreateSessionResponse = {
418
418
  sub: string;
419
419
  platformId: string;
420
420
  sourceId: string;
421
+ localSessionExpiryHandling?: boolean;
421
422
  };
422
423
 
423
424
  declare const errorSchema: z.ZodObject<{
package/index.cjs CHANGED
@@ -162,13 +162,18 @@ class IntentController {
162
162
  body: JSON.stringify({ findOptions }),
163
163
  });
164
164
  if (!startResponse.ok) {
165
- throw new Error(startResponse.statusText);
165
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
166
166
  }
167
167
  // TODO: type this response?
168
168
  const json = await startResponse.json();
169
169
  this.#discovery.id = json.discoveryId;
170
170
  this.#discovery.sessionCount = json.sessionCount;
171
171
  this.#discovery.state = 'in-progress';
172
+ if (this.#discovery.sessionCount === 1) {
173
+ // since we have no other connected sessions, we can end discovery immediately
174
+ await this.#endIntentDiscovery(false);
175
+ return;
176
+ }
172
177
  // Listen out for discovery results directly sent to us
173
178
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
174
179
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -179,10 +184,8 @@ class IntentController {
179
184
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
180
185
  }
181
186
  }
182
- async #endIntentDiscovery() {
187
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
183
188
  if (this.#discovery.state !== 'in-progress') {
184
- // TODO: remove debug logs
185
- this.#logger('debug', 'Intent discovery not in progress');
186
189
  return;
187
190
  }
188
191
  if (this.#discoveryTimeout) {
@@ -192,10 +195,12 @@ class IntentController {
192
195
  this.#discovery.state = 'ended';
193
196
  // emit our aggregated events
194
197
  this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
195
- // gracefully end discovery
196
- await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
197
- this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
198
- });
198
+ if (mqttUnsubscribe) {
199
+ // gracefully end discovery
200
+ await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
201
+ this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
202
+ });
203
+ }
199
204
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
200
205
  method: 'DELETE',
201
206
  headers: getRequestHeaders(this.#connectionParams),
@@ -204,7 +209,6 @@ class IntentController {
204
209
  if (!deleteResponse.ok) {
205
210
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
206
211
  }
207
- this.#logger('debug', 'Intent discovery ended');
208
212
  })
209
213
  .catch((error) => {
210
214
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -249,7 +253,8 @@ class IntentController {
249
253
  }
250
254
  async sendIntentResult(initiatingSessionId, result) {
251
255
  if (!isErrorIntentResult(result)) {
252
- // cloud-encode the source app id
256
+ // cloud-encode the source app id to support chained intent actions over cloud
257
+ // https://fdc3.finos.org/docs/2.0/api/spec#resolution-object -> "Use metadata about the resolving app instance to target a further intent"
253
258
  const source = getSourceFromSession(this.#sessionDetails);
254
259
  const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
255
260
  result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
@@ -348,6 +353,7 @@ class CloudInteropAPI {
348
353
  #attemptingToReconnect = false;
349
354
  #events = new EventController();
350
355
  #intents;
356
+ #sessionTimer;
351
357
  constructor(cloudInteropSettings) {
352
358
  this.#cloudInteropSettings = cloudInteropSettings;
353
359
  }
@@ -360,11 +366,11 @@ class CloudInteropAPI {
360
366
  /**
361
367
  * Connects and creates a session on the Cloud Interop service
362
368
  *
363
- * @param {ConnectParameters} parameters - The parameters to use to connect
364
- * @returns {*} {Promise<void>}
369
+ * @param parameters - The parameters to use to connect
370
+ * @returns Promise that resolves when connection is established
365
371
  * @memberof CloudInteropAPI
366
- * @throws {CloudInteropAPIError} - If an error occurs during connection
367
- * @throws {AuthorizationError} - If the connection is unauthorized
372
+ * @throws CloudInteropAPIError - If an error occurs during connection
373
+ * @throws AuthorizationError - If the connection is unauthorized
368
374
  */
369
375
  async connect(parameters) {
370
376
  this.#validateConnectParams(parameters);
@@ -388,6 +394,11 @@ class CloudInteropAPI {
388
394
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
389
395
  }
390
396
  this.#sessionDetails = (await createSessionResponse.json());
397
+ // If local session expiry handling is enabled, start the session timer
398
+ if (this.#sessionDetails.localSessionExpiryHandling) {
399
+ this.#logger('debug', `Local session expiry handling is enabled`);
400
+ this.#startSessionTimer();
401
+ }
391
402
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
392
403
  const clientOptions = {
393
404
  keepalive: this.#keepAliveIntervalSeconds,
@@ -417,9 +428,7 @@ class CloudInteropAPI {
417
428
  if (error instanceof mqtt.ErrorWithReasonCode) {
418
429
  switch (error.code) {
419
430
  case BadUserNamePasswordError: {
420
- await this.#disconnect(false);
421
- this.#logger('warn', `Session expired`);
422
- this.#events.emitEvent('session-expired');
431
+ this.#handleSessionExpiry();
423
432
  return;
424
433
  }
425
434
  default: {
@@ -473,9 +482,9 @@ class CloudInteropAPI {
473
482
  /**
474
483
  * Disconnects from the Cloud Interop service
475
484
  *
476
- * @returns {*} {Promise<void>}
485
+ * @returns Promise that resolves when disconnected
477
486
  * @memberof CloudInteropAPI
478
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
487
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
479
488
  */
480
489
  async disconnect() {
481
490
  await this.#disconnect(true);
@@ -483,9 +492,9 @@ class CloudInteropAPI {
483
492
  /**
484
493
  * Publishes a new context for the given context group to the other connected sessions
485
494
  *
486
- * @param {string} contextGroup - The context group to publish to
487
- * @param {object} context - The context to publish
488
- * @returns {*} {Promise<void>}
495
+ * @param contextGroup - The context group to publish to
496
+ * @param context - The context to publish
497
+ * @returns Promise that resolves when context is published
489
498
  * @memberof CloudInteropAPI
490
499
  */
491
500
  async setContext(contextGroup, context) {
@@ -512,9 +521,9 @@ class CloudInteropAPI {
512
521
  /**
513
522
  * Starts an intent discovery operation
514
523
  *
515
- * @returns {*} {Promise<void>}
524
+ * @returns Promise that resolves when intent discovery is started
516
525
  * @memberof CloudInteropAPI
517
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
526
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
518
527
  */
519
528
  async startIntentDiscovery(options) {
520
529
  this.#throwIfNotConnected();
@@ -555,6 +564,11 @@ class CloudInteropAPI {
555
564
  if (!this.#connectionParams) {
556
565
  throw new Error('Connect parameters must be provided');
557
566
  }
567
+ // Cancel session timer if it's running
568
+ if (this.#sessionTimer) {
569
+ clearTimeout(this.#sessionTimer);
570
+ this.#sessionTimer = undefined;
571
+ }
558
572
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
559
573
  method: 'DELETE',
560
574
  headers: getRequestHeaders(this.#connectionParams),
@@ -590,8 +604,13 @@ class CloudInteropAPI {
590
604
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
591
605
  return;
592
606
  }
593
- const { contextGroup, context, source, history } = contextEvent;
594
- this.#events.emitEvent('context', { contextGroup, context, source, history: { ...history, clientReceived: Date.now() } });
607
+ const { context, payload, contextGroup, channelName, source, history } = contextEvent;
608
+ this.#events.emitEvent('context', {
609
+ contextGroup: channelName || contextGroup,
610
+ context: payload || context,
611
+ source,
612
+ history: { ...history, clientReceived: Date.now() },
613
+ });
595
614
  }
596
615
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
597
616
  this.#handleCommandMessage(messageEnvelope);
@@ -648,6 +667,88 @@ class CloudInteropAPI {
648
667
  throw new Error('MQTT client not connected');
649
668
  }
650
669
  }
670
+ /**
671
+ * Extracts the expiration timestamp from a JWT token.
672
+ *
673
+ * @param token - The JWT token string
674
+ * @returns The expiration timestamp in seconds, or null if extraction fails
675
+ */
676
+ #extractExpirationFromJwt(token) {
677
+ try {
678
+ // JWT tokens have three parts separated by dots: header.payload.signature
679
+ // The exp claim is in the payload
680
+ const parts = token.split('.');
681
+ if (parts.length < 2) {
682
+ this.#logger('warn', 'Invalid JWT token format: expected at least 2 parts');
683
+ return null;
684
+ }
685
+ const payload = parts[1];
686
+ // Decode base64url encoded payload
687
+ const decodedBytes = buffer.Buffer.from(payload, 'base64url');
688
+ const payloadJson = decodedBytes.toString('utf8');
689
+ // Parse JSON to get the exp claim
690
+ const claims = JSON.parse(payloadJson);
691
+ const exp = claims.exp;
692
+ if (exp === undefined || exp === null) {
693
+ this.#logger('warn', "JWT token does not contain 'exp' claim");
694
+ return null;
695
+ }
696
+ if (typeof exp !== 'number') {
697
+ this.#logger('warn', `JWT token 'exp' claim is not a number: ${exp}`);
698
+ return null;
699
+ }
700
+ return exp;
701
+ }
702
+ catch (error) {
703
+ this.#logger('error', `Failed to extract expiration from JWT token: ${error instanceof Error ? error.message : error}`);
704
+ return null;
705
+ }
706
+ }
707
+ /**
708
+ * Start a session timer that will expire at the time specified in the JWT token's exp claim.
709
+ * When the timer fires, it executes the same actions as the BadUserNamePasswordError case.
710
+ */
711
+ #startSessionTimer() {
712
+ if (!this.#sessionDetails?.localSessionExpiryHandling) {
713
+ return;
714
+ }
715
+ const token = this.#sessionDetails.token;
716
+ if (!token) {
717
+ this.#logger('warn', 'Cannot start session timer: token not available');
718
+ return;
719
+ }
720
+ // Extract expiration time from JWT token
721
+ const expTimestamp = this.#extractExpirationFromJwt(token);
722
+ if (expTimestamp === null) {
723
+ this.#logger('warn', 'Cannot start session timer: could not extract expiration from JWT token');
724
+ return;
725
+ }
726
+ const currentTimeSeconds = Math.floor(Date.now() / 1000);
727
+ const delaySeconds = expTimestamp - currentTimeSeconds;
728
+ if (delaySeconds <= 0) {
729
+ this.#logger('warn', 'JWT token has already expired or expires immediately');
730
+ // Execute the same actions as BadUserNamePasswordError case
731
+ this.#handleSessionExpiry();
732
+ return;
733
+ }
734
+ // Clear any existing timer
735
+ if (this.#sessionTimer) {
736
+ clearTimeout(this.#sessionTimer);
737
+ }
738
+ const expirationTimeString = new Date(expTimestamp * 1000).toISOString();
739
+ this.#logger('debug', `Starting session timer to expire in ${delaySeconds} seconds (at ${expirationTimeString})`);
740
+ this.#sessionTimer = setTimeout(async () => {
741
+ this.#handleSessionExpiry();
742
+ }, delaySeconds * 1000);
743
+ }
744
+ /**
745
+ * Handles session expiry by executing the same actions as the BadUserNamePasswordError case.
746
+ */
747
+ async #handleSessionExpiry() {
748
+ await this.#disconnect(false);
749
+ this.#logger('warn', 'Session expired');
750
+ this.#events.emitEvent('session-expired');
751
+ }
651
752
  }
652
753
 
653
754
  exports.AuthorizationError = AuthorizationError;
package/index.mjs CHANGED
@@ -160,13 +160,18 @@ class IntentController {
160
160
  body: JSON.stringify({ findOptions }),
161
161
  });
162
162
  if (!startResponse.ok) {
163
- throw new Error(startResponse.statusText);
163
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
164
164
  }
165
165
  // TODO: type this response?
166
166
  const json = await startResponse.json();
167
167
  this.#discovery.id = json.discoveryId;
168
168
  this.#discovery.sessionCount = json.sessionCount;
169
169
  this.#discovery.state = 'in-progress';
170
+ if (this.#discovery.sessionCount === 1) {
171
+ // since we have no other connected sessions, we can end discovery immediately
172
+ await this.#endIntentDiscovery(false);
173
+ return;
174
+ }
170
175
  // Listen out for discovery results directly sent to us
171
176
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
172
177
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -177,10 +182,8 @@ class IntentController {
177
182
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
178
183
  }
179
184
  }
180
- async #endIntentDiscovery() {
185
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
181
186
  if (this.#discovery.state !== 'in-progress') {
182
- // TODO: remove debug logs
183
- this.#logger('debug', 'Intent discovery not in progress');
184
187
  return;
185
188
  }
186
189
  if (this.#discoveryTimeout) {
@@ -190,10 +193,12 @@ class IntentController {
190
193
  this.#discovery.state = 'ended';
191
194
  // emit our aggregated events
192
195
  this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
193
- // gracefully end discovery
194
- await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
195
- this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
196
- });
196
+ if (mqttUnsubscribe) {
197
+ // gracefully end discovery
198
+ await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
199
+ this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
200
+ });
201
+ }
197
202
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
198
203
  method: 'DELETE',
199
204
  headers: getRequestHeaders(this.#connectionParams),
@@ -202,7 +207,6 @@ class IntentController {
202
207
  if (!deleteResponse.ok) {
203
208
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
204
209
  }
205
- this.#logger('debug', 'Intent discovery ended');
206
210
  })
207
211
  .catch((error) => {
208
212
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -247,7 +251,8 @@ class IntentController {
247
251
  }
248
252
  async sendIntentResult(initiatingSessionId, result) {
249
253
  if (!isErrorIntentResult(result)) {
250
- // cloud-encode the source app id
254
+ // cloud-encode the source app id to support chained intent actions over cloud
255
+ // https://fdc3.finos.org/docs/2.0/api/spec#resolution-object -> "Use metadata about the resolving app instance to target a further intent"
251
256
  const source = getSourceFromSession(this.#sessionDetails);
252
257
  const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
253
258
  result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
@@ -346,6 +351,7 @@ class CloudInteropAPI {
346
351
  #attemptingToReconnect = false;
347
352
  #events = new EventController();
348
353
  #intents;
354
+ #sessionTimer;
349
355
  constructor(cloudInteropSettings) {
350
356
  this.#cloudInteropSettings = cloudInteropSettings;
351
357
  }
@@ -358,11 +364,11 @@ class CloudInteropAPI {
358
364
  /**
359
365
  * Connects and creates a session on the Cloud Interop service
360
366
  *
361
- * @param {ConnectParameters} parameters - The parameters to use to connect
362
- * @returns {*} {Promise<void>}
367
+ * @param parameters - The parameters to use to connect
368
+ * @returns Promise that resolves when connection is established
363
369
  * @memberof CloudInteropAPI
364
- * @throws {CloudInteropAPIError} - If an error occurs during connection
365
- * @throws {AuthorizationError} - If the connection is unauthorized
370
+ * @throws CloudInteropAPIError - If an error occurs during connection
371
+ * @throws AuthorizationError - If the connection is unauthorized
366
372
  */
367
373
  async connect(parameters) {
368
374
  this.#validateConnectParams(parameters);
@@ -386,6 +392,11 @@ class CloudInteropAPI {
386
392
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
387
393
  }
388
394
  this.#sessionDetails = (await createSessionResponse.json());
395
+ // If local session expiry handling is enabled, start the session timer
396
+ if (this.#sessionDetails.localSessionExpiryHandling) {
397
+ this.#logger('debug', `Local session expiry handling is enabled`);
398
+ this.#startSessionTimer();
399
+ }
389
400
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
390
401
  const clientOptions = {
391
402
  keepalive: this.#keepAliveIntervalSeconds,
@@ -415,9 +426,7 @@ class CloudInteropAPI {
415
426
  if (error instanceof mqtt.ErrorWithReasonCode) {
416
427
  switch (error.code) {
417
428
  case BadUserNamePasswordError: {
418
- await this.#disconnect(false);
419
- this.#logger('warn', `Session expired`);
420
- this.#events.emitEvent('session-expired');
429
+ this.#handleSessionExpiry();
421
430
  return;
422
431
  }
423
432
  default: {
@@ -471,9 +480,9 @@ class CloudInteropAPI {
471
480
  /**
472
481
  * Disconnects from the Cloud Interop service
473
482
  *
474
- * @returns {*} {Promise<void>}
483
+ * @returns Promise that resolves when disconnected
475
484
  * @memberof CloudInteropAPI
476
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
485
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
477
486
  */
478
487
  async disconnect() {
479
488
  await this.#disconnect(true);
@@ -481,9 +490,9 @@ class CloudInteropAPI {
481
490
  /**
482
491
  * Publishes a new context for the given context group to the other connected sessions
483
492
  *
484
- * @param {string} contextGroup - The context group to publish to
485
- * @param {object} context - The context to publish
486
- * @returns {*} {Promise<void>}
493
+ * @param contextGroup - The context group to publish to
494
+ * @param context - The context to publish
495
+ * @returns Promise that resolves when context is published
487
496
  * @memberof CloudInteropAPI
488
497
  */
489
498
  async setContext(contextGroup, context) {
@@ -510,9 +519,9 @@ class CloudInteropAPI {
510
519
  /**
511
520
  * Starts an intent discovery operation
512
521
  *
513
- * @returns {*} {Promise<void>}
522
+ * @returns Promise that resolves when intent discovery is started
514
523
  * @memberof CloudInteropAPI
515
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
524
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
516
525
  */
517
526
  async startIntentDiscovery(options) {
518
527
  this.#throwIfNotConnected();
@@ -553,6 +562,11 @@ class CloudInteropAPI {
553
562
  if (!this.#connectionParams) {
554
563
  throw new Error('Connect parameters must be provided');
555
564
  }
565
+ // Cancel session timer if it's running
566
+ if (this.#sessionTimer) {
567
+ clearTimeout(this.#sessionTimer);
568
+ this.#sessionTimer = undefined;
569
+ }
556
570
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
557
571
  method: 'DELETE',
558
572
  headers: getRequestHeaders(this.#connectionParams),
@@ -588,8 +602,13 @@ class CloudInteropAPI {
588
602
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
589
603
  return;
590
604
  }
591
- const { contextGroup, context, source, history } = contextEvent;
592
- this.#events.emitEvent('context', { contextGroup, context, source, history: { ...history, clientReceived: Date.now() } });
605
+ const { context, payload, contextGroup, channelName, source, history } = contextEvent;
606
+ this.#events.emitEvent('context', {
607
+ contextGroup: channelName || contextGroup,
608
+ context: payload || context,
609
+ source,
610
+ history: { ...history, clientReceived: Date.now() },
611
+ });
593
612
  }
594
613
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
595
614
  this.#handleCommandMessage(messageEnvelope);
@@ -646,6 +665,88 @@ class CloudInteropAPI {
646
665
  throw new Error('MQTT client not connected');
647
666
  }
648
667
  }
668
+ /**
669
+ * Extracts the expiration timestamp from a JWT token.
670
+ *
671
+ * @param token - The JWT token string
672
+ * @returns The expiration timestamp in seconds, or null if extraction fails
673
+ */
674
+ #extractExpirationFromJwt(token) {
675
+ try {
676
+ // JWT tokens have three parts separated by dots: header.payload.signature
677
+ // The exp claim is in the payload
678
+ const parts = token.split('.');
679
+ if (parts.length < 2) {
680
+ this.#logger('warn', 'Invalid JWT token format: expected at least 2 parts');
681
+ return null;
682
+ }
683
+ const payload = parts[1];
684
+ // Decode base64url encoded payload
685
+ const decodedBytes = Buffer.from(payload, 'base64url');
686
+ const payloadJson = decodedBytes.toString('utf8');
687
+ // Parse JSON to get the exp claim
688
+ const claims = JSON.parse(payloadJson);
689
+ const exp = claims.exp;
690
+ if (exp === undefined || exp === null) {
691
+ this.#logger('warn', "JWT token does not contain 'exp' claim");
692
+ return null;
693
+ }
694
+ if (typeof exp !== 'number') {
695
+ this.#logger('warn', `JWT token 'exp' claim is not a number: ${exp}`);
696
+ return null;
697
+ }
698
+ return exp;
699
+ }
700
+ catch (error) {
701
+ this.#logger('error', `Failed to extract expiration from JWT token: ${error instanceof Error ? error.message : error}`);
702
+ return null;
703
+ }
704
+ }
705
+ /**
706
+ * Start a session timer that will expire at the time specified in the JWT token's exp claim.
707
+ * When the timer fires, it executes the same actions as the BadUserNamePasswordError case.
708
+ */
709
+ #startSessionTimer() {
710
+ if (!this.#sessionDetails?.localSessionExpiryHandling) {
711
+ return;
712
+ }
713
+ const token = this.#sessionDetails.token;
714
+ if (!token) {
715
+ this.#logger('warn', 'Cannot start session timer: token not available');
716
+ return;
717
+ }
718
+ // Extract expiration time from JWT token
719
+ const expTimestamp = this.#extractExpirationFromJwt(token);
720
+ if (expTimestamp === null) {
721
+ this.#logger('warn', 'Cannot start session timer: could not extract expiration from JWT token');
722
+ return;
723
+ }
724
+ const currentTimeSeconds = Math.floor(Date.now() / 1000);
725
+ const delaySeconds = expTimestamp - currentTimeSeconds;
726
+ if (delaySeconds <= 0) {
727
+ this.#logger('warn', 'JWT token has already expired or expires immediately');
728
+ // Execute the same actions as BadUserNamePasswordError case
729
+ this.#handleSessionExpiry();
730
+ return;
731
+ }
732
+ // Clear any existing timer
733
+ if (this.#sessionTimer) {
734
+ clearTimeout(this.#sessionTimer);
735
+ }
736
+ const expirationTimeString = new Date(expTimestamp * 1000).toISOString();
737
+ this.#logger('debug', `Starting session timer to expire in ${delaySeconds} seconds (at ${expirationTimeString})`);
738
+ this.#sessionTimer = setTimeout(async () => {
739
+ this.#handleSessionExpiry();
740
+ }, delaySeconds * 1000);
741
+ }
742
+ /**
743
+ * Handles session expiry by executing the same actions as the BadUserNamePasswordError case.
744
+ */
745
+ async #handleSessionExpiry() {
746
+ await this.#disconnect(false);
747
+ this.#logger('warn', 'Session expired');
748
+ this.#events.emitEvent('session-expired');
749
+ }
649
750
  }
650
751
 
651
752
  export { AuthorizationError, CloudInteropAPI, CloudInteropAPIError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfin/cloud-interop-core-api",
3
- "version": "0.0.1-alpha.99f112a",
3
+ "version": "0.0.1-alpha.9a0045a",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "main": "./index.cjs",
@@ -8,11 +8,11 @@
8
8
  "types": "./bundle.d.ts",
9
9
  "author": "",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
+ "dependencies": {
12
+ "mqtt": "^5.13.0",
13
+ "zod": "^3.24.4"
14
+ },
11
15
  "optionalDependencies": {
12
16
  "@rollup/rollup-linux-x64-gnu": "*"
13
- },
14
- "dependencies": {
15
- "mqtt": "^5.3.1",
16
- "zod": "^3.24.1"
17
17
  }
18
18
  }