@openfin/cloud-interop-core-api 0.0.1-alpha.e8aa2c9 → 0.0.1-alpha.e8e2853

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,8 +418,17 @@ export declare type CreateSessionResponse = {
418
418
  sub: string;
419
419
  platformId: string;
420
420
  sourceId: string;
421
+ localSessionExpiryHandling?: boolean;
421
422
  };
422
423
 
424
+ declare const errorSchema: z.ZodObject<{
425
+ error: z.ZodString;
426
+ }, "strip", z.ZodTypeAny, {
427
+ error: string;
428
+ }, {
429
+ error: string;
430
+ }>;
431
+
423
432
  export declare type EventListenersMap = Map<keyof EventMap, Array<(...args: Parameters<EventMap[keyof EventMap]>) => void>>;
424
433
 
425
434
  export declare type EventMap = {
@@ -545,6 +554,8 @@ declare type HistoryRecord = {
545
554
  */
546
555
  export declare type InferredContext = z.infer<typeof contextSchema>;
547
556
 
557
+ export declare type InferredError = z.infer<typeof errorSchema>;
558
+
548
559
  /**
549
560
  * @internal
550
561
  */
package/index.cjs CHANGED
@@ -3,6 +3,8 @@
3
3
  var buffer = require('buffer');
4
4
  var mqtt = require('mqtt');
5
5
 
6
+ var l=t=>{let e=t.replaceAll("-","+").replaceAll("_","/");return e.padEnd(e.length+(4-e.length%4)%4,"=")};
7
+
6
8
  class CloudInteropAPIError extends Error {
7
9
  code;
8
10
  constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
@@ -46,6 +48,8 @@ class EventController {
46
48
  }
47
49
  }
48
50
 
51
+ const isErrorIntentResult = (result) => 'error' in result;
52
+
49
53
  const APP_ID_DELIM = '::';
50
54
  const getRequestHeaders = (connectionParameters) => {
51
55
  const headers = {};
@@ -72,13 +76,9 @@ const getRequestHeaders = (connectionParameters) => {
72
76
  * @param source
73
77
  * @returns
74
78
  */
75
- const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map((intent) => ({
79
+ const encodeAppIntents = (appIntents, source) => appIntents.map((intent) => ({
76
80
  ...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
- }),
81
+ apps: intent.apps.map((app) => ({ ...app, appId: encodeAppId(app.appId, source) })),
82
82
  }));
83
83
  /**
84
84
  * Decodes all app intents by URI decoding the parts previously encoded by `encodeAppIntents`
@@ -87,13 +87,19 @@ const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map
87
87
  */
88
88
  const decodeAppIntents = (appIntents) => appIntents.map((intent) => ({
89
89
  ...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
- }),
90
+ apps: intent.apps.map((app) => ({ ...app, appId: decodeAppId(app.appId) })),
96
91
  }));
92
+ const encodeAppId = (appIdString, { sessionId, sourceId }) => {
93
+ const id = encodeURIComponent(appIdString);
94
+ const sId = encodeURIComponent(sourceId);
95
+ return `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}`;
96
+ };
97
+ const decodeAppId = (appId) => {
98
+ const [encodedAppId, encodedSourceId, sessionId] = appId.split(APP_ID_DELIM);
99
+ const id = decodeURIComponent(encodedAppId);
100
+ const sourceId = decodeURIComponent(encodedSourceId);
101
+ return `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}`;
102
+ };
97
103
  /**
98
104
  * Decodes the AppIdentifier to extract the appId, sourceId, and sessionId.
99
105
  * @returns an object with:
@@ -158,13 +164,18 @@ class IntentController {
158
164
  body: JSON.stringify({ findOptions }),
159
165
  });
160
166
  if (!startResponse.ok) {
161
- throw new Error(startResponse.statusText);
167
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
162
168
  }
163
169
  // TODO: type this response?
164
170
  const json = await startResponse.json();
165
171
  this.#discovery.id = json.discoveryId;
166
172
  this.#discovery.sessionCount = json.sessionCount;
167
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
+ }
168
179
  // Listen out for discovery results directly sent to us
169
180
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
170
181
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -175,10 +186,8 @@ class IntentController {
175
186
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
176
187
  }
177
188
  }
178
- async #endIntentDiscovery() {
189
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
179
190
  if (this.#discovery.state !== 'in-progress') {
180
- // TODO: remove debug logs
181
- this.#logger('debug', 'Intent discovery not in progress');
182
191
  return;
183
192
  }
184
193
  if (this.#discoveryTimeout) {
@@ -188,10 +197,12 @@ class IntentController {
188
197
  this.#discovery.state = 'ended';
189
198
  // emit our aggregated events
190
199
  this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
191
- // gracefully end discovery
192
- await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
193
- this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
194
- });
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
+ }
195
206
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
196
207
  method: 'DELETE',
197
208
  headers: getRequestHeaders(this.#connectionParams),
@@ -200,7 +211,6 @@ class IntentController {
200
211
  if (!deleteResponse.ok) {
201
212
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
202
213
  }
203
- this.#logger('debug', 'Intent discovery ended');
204
214
  })
205
215
  .catch((error) => {
206
216
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -244,6 +254,13 @@ class IntentController {
244
254
  return false;
245
255
  }
246
256
  async sendIntentResult(initiatingSessionId, result) {
257
+ if (!isErrorIntentResult(result)) {
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"
260
+ const source = getSourceFromSession(this.#sessionDetails);
261
+ const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
262
+ result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
263
+ }
247
264
  const { sessionId } = getSourceFromSession(this.#sessionDetails);
248
265
  const resultResponse = await fetch(`${this.#url}/api/intents/${initiatingSessionId}/result/${sessionId}`, {
249
266
  method: 'POST',
@@ -338,6 +355,7 @@ class CloudInteropAPI {
338
355
  #attemptingToReconnect = false;
339
356
  #events = new EventController();
340
357
  #intents;
358
+ #sessionTimer;
341
359
  constructor(cloudInteropSettings) {
342
360
  this.#cloudInteropSettings = cloudInteropSettings;
343
361
  }
@@ -350,11 +368,11 @@ class CloudInteropAPI {
350
368
  /**
351
369
  * Connects and creates a session on the Cloud Interop service
352
370
  *
353
- * @param {ConnectParameters} parameters - The parameters to use to connect
354
- * @returns {*} {Promise<void>}
371
+ * @param parameters - The parameters to use to connect
372
+ * @returns Promise that resolves when connection is established
355
373
  * @memberof CloudInteropAPI
356
- * @throws {CloudInteropAPIError} - If an error occurs during connection
357
- * @throws {AuthorizationError} - If the connection is unauthorized
374
+ * @throws CloudInteropAPIError - If an error occurs during connection
375
+ * @throws AuthorizationError - If the connection is unauthorized
358
376
  */
359
377
  async connect(parameters) {
360
378
  this.#validateConnectParams(parameters);
@@ -378,6 +396,11 @@ class CloudInteropAPI {
378
396
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
379
397
  }
380
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
+ }
381
404
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
382
405
  const clientOptions = {
383
406
  keepalive: this.#keepAliveIntervalSeconds,
@@ -407,9 +430,7 @@ class CloudInteropAPI {
407
430
  if (error instanceof mqtt.ErrorWithReasonCode) {
408
431
  switch (error.code) {
409
432
  case BadUserNamePasswordError: {
410
- await this.#disconnect(false);
411
- this.#logger('warn', `Session expired`);
412
- this.#events.emitEvent('session-expired');
433
+ this.#handleSessionExpiry();
413
434
  return;
414
435
  }
415
436
  default: {
@@ -463,9 +484,9 @@ class CloudInteropAPI {
463
484
  /**
464
485
  * Disconnects from the Cloud Interop service
465
486
  *
466
- * @returns {*} {Promise<void>}
487
+ * @returns Promise that resolves when disconnected
467
488
  * @memberof CloudInteropAPI
468
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
489
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
469
490
  */
470
491
  async disconnect() {
471
492
  await this.#disconnect(true);
@@ -473,9 +494,9 @@ class CloudInteropAPI {
473
494
  /**
474
495
  * Publishes a new context for the given context group to the other connected sessions
475
496
  *
476
- * @param {string} contextGroup - The context group to publish to
477
- * @param {object} context - The context to publish
478
- * @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
479
500
  * @memberof CloudInteropAPI
480
501
  */
481
502
  async setContext(contextGroup, context) {
@@ -502,9 +523,9 @@ class CloudInteropAPI {
502
523
  /**
503
524
  * Starts an intent discovery operation
504
525
  *
505
- * @returns {*} {Promise<void>}
526
+ * @returns Promise that resolves when intent discovery is started
506
527
  * @memberof CloudInteropAPI
507
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
528
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
508
529
  */
509
530
  async startIntentDiscovery(options) {
510
531
  this.#throwIfNotConnected();
@@ -545,6 +566,11 @@ class CloudInteropAPI {
545
566
  if (!this.#connectionParams) {
546
567
  throw new Error('Connect parameters must be provided');
547
568
  }
569
+ // Cancel session timer if it's running
570
+ if (this.#sessionTimer) {
571
+ clearTimeout(this.#sessionTimer);
572
+ this.#sessionTimer = undefined;
573
+ }
548
574
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
549
575
  method: 'DELETE',
550
576
  headers: getRequestHeaders(this.#connectionParams),
@@ -580,8 +606,13 @@ class CloudInteropAPI {
580
606
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
581
607
  return;
582
608
  }
583
- const { contextGroup, context, source, history } = contextEvent;
584
- 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
+ });
585
616
  }
586
617
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
587
618
  this.#handleCommandMessage(messageEnvelope);
@@ -638,6 +669,88 @@ class CloudInteropAPI {
638
669
  throw new Error('MQTT client not connected');
639
670
  }
640
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.Buffer.from(l(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
+ }
641
754
  }
642
755
 
643
756
  exports.AuthorizationError = AuthorizationError;
package/index.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import mqtt from 'mqtt';
3
3
 
4
+ var l=t=>{let e=t.replaceAll("-","+").replaceAll("_","/");return e.padEnd(e.length+(4-e.length%4)%4,"=")};
5
+
4
6
  class CloudInteropAPIError extends Error {
5
7
  code;
6
8
  constructor(message = 'An unexpected error has occurred', code = 'UNEXPECTED_ERROR', cause) {
@@ -44,6 +46,8 @@ class EventController {
44
46
  }
45
47
  }
46
48
 
49
+ const isErrorIntentResult = (result) => 'error' in result;
50
+
47
51
  const APP_ID_DELIM = '::';
48
52
  const getRequestHeaders = (connectionParameters) => {
49
53
  const headers = {};
@@ -70,13 +74,9 @@ const getRequestHeaders = (connectionParameters) => {
70
74
  * @param source
71
75
  * @returns
72
76
  */
73
- const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map((intent) => ({
77
+ const encodeAppIntents = (appIntents, source) => appIntents.map((intent) => ({
74
78
  ...intent,
75
- apps: intent.apps.map((app) => {
76
- const id = encodeURIComponent(app.appId);
77
- const sId = encodeURIComponent(sourceId);
78
- return { ...app, appId: `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}` };
79
- }),
79
+ apps: intent.apps.map((app) => ({ ...app, appId: encodeAppId(app.appId, source) })),
80
80
  }));
81
81
  /**
82
82
  * Decodes all app intents by URI decoding the parts previously encoded by `encodeAppIntents`
@@ -85,13 +85,19 @@ const encodeAppIntents = (appIntents, { sessionId, sourceId }) => appIntents.map
85
85
  */
86
86
  const decodeAppIntents = (appIntents) => appIntents.map((intent) => ({
87
87
  ...intent,
88
- apps: intent.apps.map((app) => {
89
- const [encodedAppId, encodedSourceId, sessionId] = app.appId.split(APP_ID_DELIM);
90
- const id = decodeURIComponent(encodedAppId);
91
- const sourceId = decodeURIComponent(encodedSourceId);
92
- return { ...app, appId: `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}` };
93
- }),
88
+ apps: intent.apps.map((app) => ({ ...app, appId: decodeAppId(app.appId) })),
94
89
  }));
90
+ const encodeAppId = (appIdString, { sessionId, sourceId }) => {
91
+ const id = encodeURIComponent(appIdString);
92
+ const sId = encodeURIComponent(sourceId);
93
+ return `${id}${APP_ID_DELIM}${sId}${APP_ID_DELIM}${sessionId}`;
94
+ };
95
+ const decodeAppId = (appId) => {
96
+ const [encodedAppId, encodedSourceId, sessionId] = appId.split(APP_ID_DELIM);
97
+ const id = decodeURIComponent(encodedAppId);
98
+ const sourceId = decodeURIComponent(encodedSourceId);
99
+ return `${id}${APP_ID_DELIM}${sourceId}${APP_ID_DELIM}${sessionId}`;
100
+ };
95
101
  /**
96
102
  * Decodes the AppIdentifier to extract the appId, sourceId, and sessionId.
97
103
  * @returns an object with:
@@ -156,13 +162,18 @@ class IntentController {
156
162
  body: JSON.stringify({ findOptions }),
157
163
  });
158
164
  if (!startResponse.ok) {
159
- throw new Error(startResponse.statusText);
165
+ throw new Error(`Error creating intent discovery record: ${startResponse.statusText}`);
160
166
  }
161
167
  // TODO: type this response?
162
168
  const json = await startResponse.json();
163
169
  this.#discovery.id = json.discoveryId;
164
170
  this.#discovery.sessionCount = json.sessionCount;
165
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
+ }
166
177
  // Listen out for discovery results directly sent to us
167
178
  await this.#mqttClient.subscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`);
168
179
  this.#discoveryTimeout = setTimeout(() => this.#endIntentDiscovery(), clampedTimeout);
@@ -173,10 +184,8 @@ class IntentController {
173
184
  throw new CloudInteropAPIError('Error starting intent discovery', 'ERR_STARTING_INTENT_DISCOVERY', error);
174
185
  }
175
186
  }
176
- async #endIntentDiscovery() {
187
+ async #endIntentDiscovery(mqttUnsubscribe = true) {
177
188
  if (this.#discovery.state !== 'in-progress') {
178
- // TODO: remove debug logs
179
- this.#logger('debug', 'Intent discovery not in progress');
180
189
  return;
181
190
  }
182
191
  if (this.#discoveryTimeout) {
@@ -186,10 +195,12 @@ class IntentController {
186
195
  this.#discovery.state = 'ended';
187
196
  // emit our aggregated events
188
197
  this.#events.emitEvent('aggregate-intent-details', { responses: this.#discovery.pendingIntentDetailsEvents });
189
- // gracefully end discovery
190
- await this.#mqttClient.unsubscribeAsync(`${this.#sessionDetails.sessionRootTopic}/commands/${this.#discovery.id}`).catch(() => {
191
- this.#logger('warn', `Error ending intent discovery: could not unsubscribe from discovery id ${this.#discovery.id}`);
192
- });
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
+ }
193
204
  await fetch(`${this.#url}/api/intents/${this.#sessionDetails.sessionId}/${this.#discovery.id}`, {
194
205
  method: 'DELETE',
195
206
  headers: getRequestHeaders(this.#connectionParams),
@@ -198,7 +209,6 @@ class IntentController {
198
209
  if (!deleteResponse.ok) {
199
210
  throw new Error(`Error ending intent discovery: ${deleteResponse.statusText}`);
200
211
  }
201
- this.#logger('debug', 'Intent discovery ended');
202
212
  })
203
213
  .catch((error) => {
204
214
  this.#logger('warn', `Error ending intent discovery: ${error}`);
@@ -242,6 +252,13 @@ class IntentController {
242
252
  return false;
243
253
  }
244
254
  async sendIntentResult(initiatingSessionId, result) {
255
+ if (!isErrorIntentResult(result)) {
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"
258
+ const source = getSourceFromSession(this.#sessionDetails);
259
+ const encoded = encodeAppId(typeof result.source === 'string' ? result.source : result.source.appId, source);
260
+ result.source = typeof result.source === 'string' ? encoded : { ...result.source, appId: encoded };
261
+ }
245
262
  const { sessionId } = getSourceFromSession(this.#sessionDetails);
246
263
  const resultResponse = await fetch(`${this.#url}/api/intents/${initiatingSessionId}/result/${sessionId}`, {
247
264
  method: 'POST',
@@ -336,6 +353,7 @@ class CloudInteropAPI {
336
353
  #attemptingToReconnect = false;
337
354
  #events = new EventController();
338
355
  #intents;
356
+ #sessionTimer;
339
357
  constructor(cloudInteropSettings) {
340
358
  this.#cloudInteropSettings = cloudInteropSettings;
341
359
  }
@@ -348,11 +366,11 @@ class CloudInteropAPI {
348
366
  /**
349
367
  * Connects and creates a session on the Cloud Interop service
350
368
  *
351
- * @param {ConnectParameters} parameters - The parameters to use to connect
352
- * @returns {*} {Promise<void>}
369
+ * @param parameters - The parameters to use to connect
370
+ * @returns Promise that resolves when connection is established
353
371
  * @memberof CloudInteropAPI
354
- * @throws {CloudInteropAPIError} - If an error occurs during connection
355
- * @throws {AuthorizationError} - If the connection is unauthorized
372
+ * @throws CloudInteropAPIError - If an error occurs during connection
373
+ * @throws AuthorizationError - If the connection is unauthorized
356
374
  */
357
375
  async connect(parameters) {
358
376
  this.#validateConnectParams(parameters);
@@ -376,6 +394,11 @@ class CloudInteropAPI {
376
394
  throw new CloudInteropAPIError(`Failed to connect to the Cloud Interop service: ${this.#cloudInteropSettings.url}`, 'ERR_CONNECT', new Error(createSessionResponse.statusText));
377
395
  }
378
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
+ }
379
402
  const sessionRootTopic = this.#sessionDetails.sessionRootTopic;
380
403
  const clientOptions = {
381
404
  keepalive: this.#keepAliveIntervalSeconds,
@@ -405,9 +428,7 @@ class CloudInteropAPI {
405
428
  if (error instanceof mqtt.ErrorWithReasonCode) {
406
429
  switch (error.code) {
407
430
  case BadUserNamePasswordError: {
408
- await this.#disconnect(false);
409
- this.#logger('warn', `Session expired`);
410
- this.#events.emitEvent('session-expired');
431
+ this.#handleSessionExpiry();
411
432
  return;
412
433
  }
413
434
  default: {
@@ -461,9 +482,9 @@ class CloudInteropAPI {
461
482
  /**
462
483
  * Disconnects from the Cloud Interop service
463
484
  *
464
- * @returns {*} {Promise<void>}
485
+ * @returns Promise that resolves when disconnected
465
486
  * @memberof CloudInteropAPI
466
- * @throws {CloudInteropAPIError} - If an error occurs during disconnection
487
+ * @throws CloudInteropAPIError - If an error occurs during disconnection
467
488
  */
468
489
  async disconnect() {
469
490
  await this.#disconnect(true);
@@ -471,9 +492,9 @@ class CloudInteropAPI {
471
492
  /**
472
493
  * Publishes a new context for the given context group to the other connected sessions
473
494
  *
474
- * @param {string} contextGroup - The context group to publish to
475
- * @param {object} context - The context to publish
476
- * @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
477
498
  * @memberof CloudInteropAPI
478
499
  */
479
500
  async setContext(contextGroup, context) {
@@ -500,9 +521,9 @@ class CloudInteropAPI {
500
521
  /**
501
522
  * Starts an intent discovery operation
502
523
  *
503
- * @returns {*} {Promise<void>}
524
+ * @returns Promise that resolves when intent discovery is started
504
525
  * @memberof CloudInteropAPI
505
- * @throws {CloudInteropAPIError} - If an error occurs during intent discovery
526
+ * @throws CloudInteropAPIError - If an error occurs during intent discovery
506
527
  */
507
528
  async startIntentDiscovery(options) {
508
529
  this.#throwIfNotConnected();
@@ -543,6 +564,11 @@ class CloudInteropAPI {
543
564
  if (!this.#connectionParams) {
544
565
  throw new Error('Connect parameters must be provided');
545
566
  }
567
+ // Cancel session timer if it's running
568
+ if (this.#sessionTimer) {
569
+ clearTimeout(this.#sessionTimer);
570
+ this.#sessionTimer = undefined;
571
+ }
546
572
  const disconnectResponse = await fetch(`${this.#cloudInteropSettings.url}/api/sessions/${this.#sessionDetails.sessionId}`, {
547
573
  method: 'DELETE',
548
574
  headers: getRequestHeaders(this.#connectionParams),
@@ -578,8 +604,13 @@ class CloudInteropAPI {
578
604
  if (contextEvent.source.sessionId === sessionDetails.sessionId) {
579
605
  return;
580
606
  }
581
- const { contextGroup, context, source, history } = contextEvent;
582
- 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
+ });
583
614
  }
584
615
  else if (topic.startsWith(`${sessionDetails.sessionRootTopic}/commands`)) {
585
616
  this.#handleCommandMessage(messageEnvelope);
@@ -636,6 +667,88 @@ class CloudInteropAPI {
636
667
  throw new Error('MQTT client not connected');
637
668
  }
638
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.from(l(payload), 'base64');
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
+ }
639
752
  }
640
753
 
641
754
  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.e8aa2c9",
4
- "type": "module",
3
+ "version": "0.0.1-alpha.e8e2853",
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": "^5.15.1",
13
+ "zod": "catalog:"
17
14
  }
18
15
  }