@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 +2 -2
- package/bundle.d.ts +26 -15
- package/index.cjs +150 -37
- package/index.mjs +150 -37
- 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,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,
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
this.#
|
|
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
|
|
354
|
-
* @returns
|
|
371
|
+
* @param parameters - The parameters to use to connect
|
|
372
|
+
* @returns Promise that resolves when connection is established
|
|
355
373
|
* @memberof CloudInteropAPI
|
|
356
|
-
* @throws
|
|
357
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
487
|
+
* @returns Promise that resolves when disconnected
|
|
467
488
|
* @memberof CloudInteropAPI
|
|
468
|
-
* @throws
|
|
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
|
|
477
|
-
* @param
|
|
478
|
-
* @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
|
|
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
|
|
526
|
+
* @returns Promise that resolves when intent discovery is started
|
|
506
527
|
* @memberof CloudInteropAPI
|
|
507
|
-
* @throws
|
|
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,
|
|
584
|
-
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
|
+
});
|
|
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,
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
this.#
|
|
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
|
|
352
|
-
* @returns
|
|
369
|
+
* @param parameters - The parameters to use to connect
|
|
370
|
+
* @returns Promise that resolves when connection is established
|
|
353
371
|
* @memberof CloudInteropAPI
|
|
354
|
-
* @throws
|
|
355
|
-
* @throws
|
|
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
|
-
|
|
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
|
|
485
|
+
* @returns Promise that resolves when disconnected
|
|
465
486
|
* @memberof CloudInteropAPI
|
|
466
|
-
* @throws
|
|
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
|
|
475
|
-
* @param
|
|
476
|
-
* @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
|
|
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
|
|
524
|
+
* @returns Promise that resolves when intent discovery is started
|
|
504
525
|
* @memberof CloudInteropAPI
|
|
505
|
-
* @throws
|
|
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,
|
|
582
|
-
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
|
+
});
|
|
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.
|
|
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.
|
|
16
|
-
"zod": "
|
|
12
|
+
"mqtt": "^5.15.1",
|
|
13
|
+
"zod": "catalog:"
|
|
17
14
|
}
|
|
18
15
|
}
|