@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 +2 -2
- package/bundle.d.ts +16 -15
- package/index.cjs +131 -26
- package/index.mjs +131 -26
- package/package.json +5 -8
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
|
@@ -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
|
-
|
|
196
|
-
|
|
197
|
-
this.#
|
|
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
|
|
364
|
-
* @returns
|
|
373
|
+
* @param parameters - The parameters to use to connect
|
|
374
|
+
* @returns Promise that resolves when connection is established
|
|
365
375
|
* @memberof CloudInteropAPI
|
|
366
|
-
* @throws
|
|
367
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
489
|
+
* @returns Promise that resolves when disconnected
|
|
477
490
|
* @memberof CloudInteropAPI
|
|
478
|
-
* @throws
|
|
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
|
|
487
|
-
* @param
|
|
488
|
-
* @returns
|
|
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
|
|
528
|
+
* @returns Promise that resolves when intent discovery is started
|
|
516
529
|
* @memberof CloudInteropAPI
|
|
517
|
-
* @throws
|
|
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,
|
|
594
|
-
this.#events.emitEvent('context', {
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
this.#
|
|
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
|
|
362
|
-
* @returns
|
|
371
|
+
* @param parameters - The parameters to use to connect
|
|
372
|
+
* @returns Promise that resolves when connection is established
|
|
363
373
|
* @memberof CloudInteropAPI
|
|
364
|
-
* @throws
|
|
365
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
487
|
+
* @returns Promise that resolves when disconnected
|
|
475
488
|
* @memberof CloudInteropAPI
|
|
476
|
-
* @throws
|
|
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
|
|
485
|
-
* @param
|
|
486
|
-
* @returns
|
|
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
|
|
526
|
+
* @returns Promise that resolves when intent discovery is started
|
|
514
527
|
* @memberof CloudInteropAPI
|
|
515
|
-
* @throws
|
|
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,
|
|
592
|
-
this.#events.emitEvent('context', {
|
|
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.
|
|
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": "
|
|
16
|
-
"zod": "
|
|
12
|
+
"mqtt": "catalog:",
|
|
13
|
+
"zod": "catalog:"
|
|
17
14
|
}
|
|
18
15
|
}
|