@openfin/cloud-interop-core-api 0.0.1-alpha.0034b20 → 0.0.1-alpha.00416f0

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
@@ -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) {
@@ -162,13 +166,18 @@ class IntentController {
162
166
  body: JSON.stringify({ findOptions }),
163
167
  });
164
168
  if (!startResponse.ok) {
165
- throw new Error(startResponse.statusText);
169
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
166
170
  }
167
171
  // TODO: type this response?
168
172
  const json = await startResponse.json();
169
173
  this.#discovery.id = json.discoveryId;
170
174
  this.#discovery.sessionCount = json.sessionCount;
171
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
+ }
172
181
  // Listen out for discovery results directly sent to us
173
182
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
174
183
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -179,10 +188,8 @@ class IntentController {
179
188
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
180
189
  }
181
190
  }
182
- async #endIntentDiscovery() {
191
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
183
192
  if (this.#discovery.state !== 'in-progress') {
184
- // TODO: remove debug logs
185
- this.#logger('debug', 'Intent discovery not in progress');
186
193
  return;
187
194
  }
188
195
  if (this.#discoveryTimeout) {
@@ -192,10 +199,12 @@ class IntentController {
192
199
  this.#discovery.state = 'ended';
193
200
  // emit our aggregated events
194
201
  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
- });
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
+ }
199
208
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
200
209
  method: 'DELETE',
201
210
  headers: getRequestHeaders(this.#connectionParams),
@@ -204,7 +213,6 @@ class IntentController {
204
213
  if (!deleteResponse.ok) {
205
214
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
206
215
  }
207
- this.#logger('debug', 'Intent discovery ended');
208
216
  })
209
217
  .catch((error) => {
210
218
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -249,7 +257,8 @@ class IntentController {
249
257
  }
250
258
  async sendIntentResult(initiatingSessionId, result) {
251
259
  if (!isErrorIntentResult(result)) {
252
- // cloud-encode the source app id
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"
253
262
  const source = getSourceFromSession(this.#sessionDetails);
254
263
  const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
255
264
  result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
@@ -348,6 +357,7 @@ class CloudInteropAPI {
348
357
  #attemptingToReconnect = false;
349
358
  #events = new EventController();
350
359
  #intents;
360
+ #sessionTimer;
351
361
  constructor(cloudInteropSettings) {
352
362
  this.#cloudInteropSettings = cloudInteropSettings;
353
363
  }
@@ -360,11 +370,11 @@ class CloudInteropAPI {
360
370
  /**
361
371
  * Connects and creates a session on the Cloud Interop service
362
372
  *
363
- * @param {ConnectParameters} parameters - The parameters to use to connect
364
- * @returns {*} {Promise<void>}
373
+ * @param parameters - The parameters to use to connect
374
+ * @returns Promise that resolves when connection is established
365
375
  * @memberof CloudInteropAPI
366
- * @throws {CloudInteropAPIError} - If an error occurs during connection
367
- * @throws {AuthorizationError} - If the connection is unauthorized
376
+ * @throws CloudInteropAPIError - If an error occurs during connection
377
+ * @throws AuthorizationError - If the connection is unauthorized
368
378
  */
369
379
  async connect(parameters) {
370
380
  this.#validateConnectParams(parameters);
@@ -388,6 +398,11 @@ class CloudInteropAPI {
388
398
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
389
399
  }
390
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
+ }
391
406
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
392
407
  const clientOptions = {
393
408
  keepalive: this.#keepAliveIntervalSeconds,
@@ -417,9 +432,7 @@ class CloudInteropAPI {
417
432
  if (error instanceof mqtt.ErrorWithReasonCode) {
418
433
  switch (error.code) {
419
434
  case BadUserNamePasswordError: {
420
- await this.#disconnect(false);
421
- this.#logger('warn', `Session expired`);
422
- this.#events.emitEvent('session-expired');
435
+ this.#handleSessionExpiry();
423
436
  return;
424
437
  }
425
438
  default: {
@@ -473,9 +486,9 @@ class CloudInteropAPI {
473
486
  /**
474
487
  * Disconnects from the Cloud Interop service
475
488
  *
476
- * @returns {*} {Promise<void>}
489
+ * @returns Promise that resolves when disconnected
477
490
  * @memberof CloudInteropAPI
478
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
491
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
479
492
  */
480
493
  async disconnect() {
481
494
  await this.#disconnect(true);
@@ -483,9 +496,9 @@ class CloudInteropAPI {
483
496
  /**
484
497
  * Publishes a new context for the given context group to the other connected sessions
485
498
  *
486
- * @param {string} contextGroup - The context group to publish to
487
- * @param {object} context - The context to publish
488
- * @returns {*} {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
489
502
  * @memberof CloudInteropAPI
490
503
  */
491
504
  async setContext(contextGroup, context) {
@@ -512,9 +525,9 @@ class CloudInteropAPI {
512
525
  /**
513
526
  * Starts an intent discovery operation
514
527
  *
515
- * @returns {*} {Promise<void>}
528
+ * @returns Promise that resolves when intent discovery is started
516
529
  * @memberof CloudInteropAPI
517
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
530
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
518
531
  */
519
532
  async startIntentDiscovery(options) {
520
533
  this.#throwIfNotConnected();
@@ -555,6 +568,11 @@ class CloudInteropAPI {
555
568
  if (!this.#connectionParams) {
556
569
  throw new Error('Connect parameters must be provided');
557
570
  }
571
+ // Cancel session timer if it's running
572
+ if (this.#sessionTimer) {
573
+ clearTimeout(this.#sessionTimer);
574
+ this.#sessionTimer = undefined;
575
+ }
558
576
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
559
577
  method: 'DELETE',
560
578
  headers: getRequestHeaders(this.#connectionParams),
@@ -590,8 +608,13 @@ class CloudInteropAPI {
590
608
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
591
609
  return;
592
610
  }
593
- const { contextGroup, context, source, history } = contextEvent;
594
- 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
+ });
595
618
  }
596
619
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
597
620
  this.#handleCommandMessage(messageEnvelope);
@@ -648,6 +671,88 @@ class CloudInteropAPI {
648
671
  throw new Error('MQTT client not connected');
649
672
  }
650
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
+ }
651
756
  }
652
757
 
653
758
  exports.AuthorizationError = AuthorizationError;
package/index.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Buffer } from 'buffer';
2
+ import 'fs';
3
+ import 'path';
2
4
  import mqtt from 'mqtt';
3
5
 
6
+ var u=n=>{let e=n.replaceAll("-","+").replaceAll("_","/");return e.padEnd(e.length+(4-e.length%4)%4,"=")};
7
+
4
8
  class CloudInteropAPIError extends Error {
5
9
  code;
6
10
  constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
@@ -160,13 +164,18 @@ class IntentController {
160
164
  body: JSON.stringify({ findOptions }),
161
165
  });
162
166
  if (!startResponse.ok) {
163
- throw new Error(startResponse.statusText);
167
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
164
168
  }
165
169
  // TODO: type this response?
166
170
  const json = await startResponse.json();
167
171
  this.#discovery.id = json.discoveryId;
168
172
  this.#discovery.sessionCount = json.sessionCount;
169
173
  this.#discovery.state = 'in-progress';
174
+ if (this.#discovery.sessionCount === 1) {
175
+ // since we have no other connected sessions, we can end discovery immediately
176
+ await this.#endIntentDiscovery(false);
177
+ return;
178
+ }
170
179
  // Listen out for discovery results directly sent to us
171
180
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
172
181
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -177,10 +186,8 @@ class IntentController {
177
186
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
178
187
  }
179
188
  }
180
- async #endIntentDiscovery() {
189
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
181
190
  if (this.#discovery.state !== 'in-progress') {
182
- // TODO: remove debug logs
183
- this.#logger('debug', 'Intent discovery not in progress');
184
191
  return;
185
192
  }
186
193
  if (this.#discoveryTimeout) {
@@ -190,10 +197,12 @@ class IntentController {
190
197
  this.#discovery.state = 'ended';
191
198
  // emit our aggregated events
192
199
  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
- });
200
+ if (mqttUnsubscribe) {
201
+ // gracefully end discovery
202
+ await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
203
+ this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
204
+ });
205
+ }
197
206
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
198
207
  method: 'DELETE',
199
208
  headers: getRequestHeaders(this.#connectionParams),
@@ -202,7 +211,6 @@ class IntentController {
202
211
  if (!deleteResponse.ok) {
203
212
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
204
213
  }
205
- this.#logger('debug', 'Intent discovery ended');
206
214
  })
207
215
  .catch((error) => {
208
216
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -247,7 +255,8 @@ class IntentController {
247
255
  }
248
256
  async sendIntentResult(initiatingSessionId, result) {
249
257
  if (!isErrorIntentResult(result)) {
250
- // cloud-encode the source app id
258
+ // cloud-encode the source app id to support chained intent actions over cloud
259
+ // https://fdc3.finos.org/docs/2.0/api/spec#resolution-object -> "Use metadata about the resolving app instance to target a further intent"
251
260
  const source = getSourceFromSession(this.#sessionDetails);
252
261
  const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
253
262
  result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
@@ -346,6 +355,7 @@ class CloudInteropAPI {
346
355
  #attemptingToReconnect = false;
347
356
  #events = new EventController();
348
357
  #intents;
358
+ #sessionTimer;
349
359
  constructor(cloudInteropSettings) {
350
360
  this.#cloudInteropSettings = cloudInteropSettings;
351
361
  }
@@ -358,11 +368,11 @@ class CloudInteropAPI {
358
368
  /**
359
369
  * Connects and creates a session on the Cloud Interop service
360
370
  *
361
- * @param {ConnectParameters} parameters - The parameters to use to connect
362
- * @returns {*} {Promise<void>}
371
+ * @param parameters - The parameters to use to connect
372
+ * @returns Promise that resolves when connection is established
363
373
  * @memberof CloudInteropAPI
364
- * @throws {CloudInteropAPIError} - If an error occurs during connection
365
- * @throws {AuthorizationError} - If the connection is unauthorized
374
+ * @throws CloudInteropAPIError - If an error occurs during connection
375
+ * @throws AuthorizationError - If the connection is unauthorized
366
376
  */
367
377
  async connect(parameters) {
368
378
  this.#validateConnectParams(parameters);
@@ -386,6 +396,11 @@ class CloudInteropAPI {
386
396
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
387
397
  }
388
398
  this.#sessionDetails = (await createSessionResponse.json());
399
+ // If local session expiry handling is enabled, start the session timer
400
+ if (this.#sessionDetails.localSessionExpiryHandling) {
401
+ this.#logger('debug', `Local session expiry handling is enabled`);
402
+ this.#startSessionTimer();
403
+ }
389
404
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
390
405
  const clientOptions = {
391
406
  keepalive: this.#keepAliveIntervalSeconds,
@@ -415,9 +430,7 @@ class CloudInteropAPI {
415
430
  if (error instanceof mqtt.ErrorWithReasonCode) {
416
431
  switch (error.code) {
417
432
  case BadUserNamePasswordError: {
418
- await this.#disconnect(false);
419
- this.#logger('warn', `Session expired`);
420
- this.#events.emitEvent('session-expired');
433
+ this.#handleSessionExpiry();
421
434
  return;
422
435
  }
423
436
  default: {
@@ -471,9 +484,9 @@ class CloudInteropAPI {
471
484
  /**
472
485
  * Disconnects from the Cloud Interop service
473
486
  *
474
- * @returns {*} {Promise<void>}
487
+ * @returns Promise that resolves when disconnected
475
488
  * @memberof CloudInteropAPI
476
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
489
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
477
490
  */
478
491
  async disconnect() {
479
492
  await this.#disconnect(true);
@@ -481,9 +494,9 @@ class CloudInteropAPI {
481
494
  /**
482
495
  * Publishes a new context for the given context group to the other connected sessions
483
496
  *
484
- * @param {string} contextGroup - The context group to publish to
485
- * @param {object} context - The context to publish
486
- * @returns {*} {Promise<void>}
497
+ * @param contextGroup - The context group to publish to
498
+ * @param context - The context to publish
499
+ * @returns Promise that resolves when context is published
487
500
  * @memberof CloudInteropAPI
488
501
  */
489
502
  async setContext(contextGroup, context) {
@@ -510,9 +523,9 @@ class CloudInteropAPI {
510
523
  /**
511
524
  * Starts an intent discovery operation
512
525
  *
513
- * @returns {*} {Promise<void>}
526
+ * @returns Promise that resolves when intent discovery is started
514
527
  * @memberof CloudInteropAPI
515
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
528
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
516
529
  */
517
530
  async startIntentDiscovery(options) {
518
531
  this.#throwIfNotConnected();
@@ -553,6 +566,11 @@ class CloudInteropAPI {
553
566
  if (!this.#connectionParams) {
554
567
  throw new Error('Connect parameters must be provided');
555
568
  }
569
+ // Cancel session timer if it's running
570
+ if (this.#sessionTimer) {
571
+ clearTimeout(this.#sessionTimer);
572
+ this.#sessionTimer = undefined;
573
+ }
556
574
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
557
575
  method: 'DELETE',
558
576
  headers: getRequestHeaders(this.#connectionParams),
@@ -588,8 +606,13 @@ class CloudInteropAPI {
588
606
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
589
607
  return;
590
608
  }
591
- const { contextGroup, context, source, history } = contextEvent;
592
- this.#events.emitEvent('context', { contextGroup, context, source, history: { ...history, clientReceived: Date.now() } });
609
+ const { context, payload, contextGroup, channelName, source, history } = contextEvent;
610
+ this.#events.emitEvent('context', {
611
+ contextGroup: channelName || contextGroup,
612
+ context: payload || context,
613
+ source,
614
+ history: { ...history, clientReceived: Date.now() },
615
+ });
593
616
  }
594
617
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
595
618
  this.#handleCommandMessage(messageEnvelope);
@@ -646,6 +669,88 @@ class CloudInteropAPI {
646
669
  throw new Error('MQTT client not connected');
647
670
  }
648
671
  }
672
+ /**
673
+ * Extracts the expiration timestamp from a JWT token.
674
+ *
675
+ * @param token - The JWT token string
676
+ * @returns The expiration timestamp in seconds, or null if extraction fails
677
+ */
678
+ #extractExpirationFromJwt(token) {
679
+ try {
680
+ // JWT tokens have three parts separated by dots: header.payload.signature
681
+ // The exp claim is in the payload
682
+ const parts = token.split('.');
683
+ if (parts.length < 2) {
684
+ this.#logger('warn', 'Invalid JWT token format: expected at least 2 parts');
685
+ return null;
686
+ }
687
+ const payload = parts[1];
688
+ // Decode base64url encoded payload
689
+ const decodedBytes = Buffer.from(u(payload), 'base64');
690
+ const payloadJson = decodedBytes.toString('utf8');
691
+ // Parse JSON to get the exp claim
692
+ const claims = JSON.parse(payloadJson);
693
+ const exp = claims.exp;
694
+ if (exp === undefined || exp === null) {
695
+ this.#logger('warn', "JWT token does not contain 'exp' claim");
696
+ return null;
697
+ }
698
+ if (typeof exp !== 'number') {
699
+ this.#logger('warn', `JWT token 'exp' claim is not a number: ${exp}`);
700
+ return null;
701
+ }
702
+ return exp;
703
+ }
704
+ catch (error) {
705
+ this.#logger('error', `Failed to extract expiration from JWT token: ${error instanceof Error ? error.message : error}`);
706
+ return null;
707
+ }
708
+ }
709
+ /**
710
+ * Start a session timer that will expire at the time specified in the JWT token's exp claim.
711
+ * When the timer fires, it executes the same actions as the BadUserNamePasswordError case.
712
+ */
713
+ #startSessionTimer() {
714
+ if (!this.#sessionDetails?.localSessionExpiryHandling) {
715
+ return;
716
+ }
717
+ const token = this.#sessionDetails.token;
718
+ if (!token) {
719
+ this.#logger('warn', 'Cannot start session timer: token not available');
720
+ return;
721
+ }
722
+ // Extract expiration time from JWT token
723
+ const expTimestamp = this.#extractExpirationFromJwt(token);
724
+ if (expTimestamp === null) {
725
+ this.#logger('warn', 'Cannot start session timer: could not extract expiration from JWT token');
726
+ return;
727
+ }
728
+ const currentTimeSeconds = Math.floor(Date.now() / 1000);
729
+ const delaySeconds = expTimestamp - currentTimeSeconds;
730
+ if (delaySeconds <= 0) {
731
+ this.#logger('warn', 'JWT token has already expired or expires immediately');
732
+ // Execute the same actions as BadUserNamePasswordError case
733
+ this.#handleSessionExpiry();
734
+ return;
735
+ }
736
+ // Clear any existing timer
737
+ if (this.#sessionTimer) {
738
+ clearTimeout(this.#sessionTimer);
739
+ }
740
+ const expirationTimeString = new Date(expTimestamp * 1000).toISOString();
741
+ this.#logger('debug', `Starting session timer to expire in ${delaySeconds} seconds (at ${expirationTimeString})`);
742
+ this.#sessionTimer = setTimeout(async () => {
743
+ this.#handleSessionExpiry();
744
+ }, delaySeconds * 1000);
745
+ }
746
+ /**
747
+ * Handles session expiry by executing the same actions as the BadUserNamePasswordError case.
748
+ */
749
+ async #handleSessionExpiry() {
750
+ await this.#disconnect(false);
751
+ this.#logger('warn', 'Session expired');
752
+ this.#events.emitEvent('session-expired');
753
+ }
649
754
  }
650
755
 
651
756
  export { AuthorizationError, CloudInteropAPI, CloudInteropAPIError };
package/package.json CHANGED
@@ -1,18 +1,15 @@
1
1
  {
2
2
  "name": "@openfin/cloud-interop-core-api",
3
- "version": "0.0.1-alpha.0034b20",
4
- "type": "module",
3
+ "version": "0.0.1-alpha.00416f0",
5
4
  "description": "",
5
+ "type": "module",
6
6
  "main": "./index.cjs",
7
7
  "browser": "./index.mjs",
8
8
  "types": "./bundle.d.ts",
9
- "author": "",
9
+ "author": "support@here.io",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
- "optionalDependencies": {
12
- "@rollup/rollup-linux-x64-gnu": "*"
13
- },
14
11
  "dependencies": {
15
- "mqtt": "^5.3.1",
16
- "zod": "^3.24.1"
12
+ "mqtt": "catalog:",
13
+ "zod": "catalog:"
17
14
  }
18
15
  }