@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 +2 -2
- package/bundle.d.ts +16 -15
- package/index.cjs +127 -26
- package/index.mjs +127 -26
- package/package.json +5 -5
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
|
|
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
|
|
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<
|
|
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
|
-
}
|
|
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
|
|
249
|
-
* @returns
|
|
248
|
+
* @param parameters - The parameters to use to connect
|
|
249
|
+
* @returns Promise that resolves when connection is established
|
|
250
250
|
* @memberof CloudInteropAPI
|
|
251
|
-
* @throws
|
|
252
|
-
* @throws
|
|
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
|
|
258
|
+
* @returns Promise that resolves when disconnected
|
|
259
259
|
* @memberof CloudInteropAPI
|
|
260
|
-
* @throws
|
|
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
|
|
267
|
-
* @param
|
|
268
|
-
* @returns
|
|
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:
|
|
271
|
+
setContext(contextGroup: string, context: InferredContext): Promise<void>;
|
|
272
272
|
/**
|
|
273
273
|
* Starts an intent discovery operation
|
|
274
274
|
*
|
|
275
|
-
* @returns
|
|
275
|
+
* @returns Promise that resolves when intent discovery is started
|
|
276
276
|
* @memberof CloudInteropAPI
|
|
277
|
-
* @throws
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
this.#
|
|
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
|
|
364
|
-
* @returns
|
|
369
|
+
* @param parameters - The parameters to use to connect
|
|
370
|
+
* @returns Promise that resolves when connection is established
|
|
365
371
|
* @memberof CloudInteropAPI
|
|
366
|
-
* @throws
|
|
367
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
485
|
+
* @returns Promise that resolves when disconnected
|
|
477
486
|
* @memberof CloudInteropAPI
|
|
478
|
-
* @throws
|
|
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
|
|
487
|
-
* @param
|
|
488
|
-
* @returns
|
|
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
|
|
524
|
+
* @returns Promise that resolves when intent discovery is started
|
|
516
525
|
* @memberof CloudInteropAPI
|
|
517
|
-
* @throws
|
|
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,
|
|
594
|
-
this.#events.emitEvent('context', {
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
this.#
|
|
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
|
|
362
|
-
* @returns
|
|
367
|
+
* @param parameters - The parameters to use to connect
|
|
368
|
+
* @returns Promise that resolves when connection is established
|
|
363
369
|
* @memberof CloudInteropAPI
|
|
364
|
-
* @throws
|
|
365
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
483
|
+
* @returns Promise that resolves when disconnected
|
|
475
484
|
* @memberof CloudInteropAPI
|
|
476
|
-
* @throws
|
|
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
|
|
485
|
-
* @param
|
|
486
|
-
* @returns
|
|
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
|
|
522
|
+
* @returns Promise that resolves when intent discovery is started
|
|
514
523
|
* @memberof CloudInteropAPI
|
|
515
|
-
* @throws
|
|
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,
|
|
592
|
-
this.#events.emitEvent('context', {
|
|
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.
|
|
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
|
}
|