@knime/hub-features 1.23.0 → 1.24.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @knime/hub-features
2
2
 
3
+ ## 1.24.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [6a0afa2]
8
+ - @knime/components@1.46.0
9
+
10
+ ## 1.24.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 9bc7a76: Add authUtils package
15
+ - 182d2d7: Added analyticEvents package. See README for usage and feature overview
16
+ Changed embeddingSDK analytics property on the embedding context.
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies [c4e0bfd]
21
+ - @knime/components@1.45.10
22
+
3
23
  ## 1.23.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/hub-features",
3
- "version": "1.23.0",
3
+ "version": "1.24.1",
4
4
  "description": "Vue components & composables for shared hub features",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,9 @@
26
26
  },
27
27
  "./versions": {
28
28
  "import": "./src/components/versions/index.ts"
29
+ },
30
+ "./analytics": {
31
+ "import": "./src/analytics/index.ts"
29
32
  }
30
33
  },
31
34
  "dependencies": {
@@ -36,7 +39,7 @@
36
39
  "lodash-es": "4.17.23",
37
40
  "ofetch": "^1.4.1",
38
41
  "typescript": "^5.9.3",
39
- "@knime/components": "1.45.9",
42
+ "@knime/components": "1.46.0",
40
43
  "@knime/styles": "1.15.0",
41
44
  "@knime/utils": "1.10.1"
42
45
  },
@@ -48,6 +51,7 @@
48
51
  "@vitejs/plugin-vue": "^6.0.1",
49
52
  "@vue/test-utils": "2.4.6",
50
53
  "consola": "3.4.2",
54
+ "json-schema-to-typescript": "^15.0.4",
51
55
  "vite": "^7.3.1",
52
56
  "vite-svg-loader": "5.1.0",
53
57
  "vue-tsc": "3.0.4"
@@ -57,6 +61,7 @@
57
61
  },
58
62
  "scripts": {
59
63
  "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
60
- "test:unit": "TZ='Europe/Berlin' vitest"
64
+ "test:unit": "TZ='Europe/Berlin' vitest",
65
+ "generate-analytics-events": "json2ts ./src/analyticsEvents/schema/schema.json > ./src/analyticsEvents/schema/schema.d.ts"
61
66
  }
62
67
  }
@@ -0,0 +1,87 @@
1
+ # @knime/hub-features — analyticsEvents
2
+
3
+ ## Preface
4
+
5
+ This package provides helpers related to analytics events. It provides 2 main behaviors:
6
+
7
+ - Provides a builder function that creates and normalizes event payloads
8
+ - Provides a sender function that forwards a given event payload to a third party API.
9
+ The implementation of the third-party API is up to the consumer as long as it satisfies the adapter contract
10
+
11
+ The main reason why these behaviors are split are for increased flexibility and to also enable this feature being used on a setup where the creation of the event is separated to the sending of the event. Like for example, on host/guest application combination where there's an iframe communication layer between both apps.
12
+
13
+ ## Usage examples
14
+
15
+ ### Building event payloads
16
+
17
+ As stated above, some applications might only care (or have the correct setup) to construct events based on different code paths and user interactions.
18
+
19
+ ```ts
20
+ import { analyticsEvents } from "@knime/hub-features/analytics";
21
+
22
+ // Create a small factory bound to your context
23
+ const { newEvent } = analyticsEvents.eventBuilder({ jobId: "job-123" });
24
+
25
+ // Build an event. Event ids are strongly-typed, combining their special format
26
+ // and validating payloads, if required
27
+ const event = newEvent({ id: "kai_prompted::kaiqa_button_prompt" });
28
+
29
+ // once you have this event object, the application can decide what to do with it.
30
+ // Whether that is sending it through an iframe as a message or some other event bus. OR sending it directly if it's so desired (see Sending events section)
31
+ ```
32
+
33
+ ### Keeping types in-sync
34
+
35
+ Due to the strict-type setup and the shape of the API of this package, reusing the type signature
36
+ of the function that creates the events can be tricky. However, this is helpful, for example, if you want to
37
+ wrap the create function with some other behavior of your own but keep the strong-type guarantees.
38
+ Below you can see an example for this use-case:
39
+
40
+ ```ts
41
+ import {
42
+ analyticsEvents,
43
+ type CreateEventFn,
44
+ } from "@knime/hub-features/analytics";
45
+
46
+ const myCustomFunction: CreateEventFn = (...args) => {
47
+ const { newEvent } = analyticsEvents.eventBuilder(TheNeededContext);
48
+
49
+ // do something before
50
+
51
+ const event = newEvent(...args);
52
+
53
+ // do something with event
54
+ };
55
+ ```
56
+
57
+ In this case, the type signature of `myCustomFunction` will match the one of `newEvent`, just like
58
+ in the previous example.
59
+
60
+ ### Sending events
61
+
62
+ As stated in the preface, to send events, you need to provide an adapter
63
+
64
+ ```ts
65
+ import {
66
+ analyticsEvents,
67
+ type AnalyticsAdapter,
68
+ } from "@knime/hub-features/analytics";
69
+
70
+ const myAdapter: AnalyticsAdapter = {
71
+ sendEvent({ event, metadata, idParser }) {
72
+ console.log("Parsed id", idParser(event.id));
73
+ console.log("Metadata", metadata);
74
+ console.log("Event", event)
75
+ }
76
+ }
77
+
78
+ // create the sender
79
+ const sender = analyticsEvents.eventSender(myAdapter);
80
+
81
+ // some event
82
+ const { newEvent } = analyticsEvents.eventBuilder(TheNeededContext);
83
+ const event = newEvent(...);
84
+
85
+ // then...
86
+ sender.send(event);
87
+ ```
@@ -0,0 +1,76 @@
1
+ import type {
2
+ AnalyticsAdapter,
3
+ AnalyticsPayload,
4
+ Context,
5
+ CreateEventFn,
6
+ Metadata,
7
+ } from "./types";
8
+ import { eventID } from "./utils/eventIds";
9
+ import { toSnakeCaseDeep } from "./utils/toSnakeCaseDeep";
10
+
11
+ const METADATA: Metadata = Object.freeze({
12
+ version: "v1.0",
13
+ });
14
+
15
+ export const analyticsEvents = Object.freeze({
16
+ METADATA,
17
+ /**
18
+ * This function creates an event builder, which lets you construct correct and strongly-typed
19
+ * event payloads according to the current schema used by this package
20
+ * @param context
21
+ * @returns
22
+ */
23
+ eventBuilder: (context: Context) => {
24
+ const newEvent: CreateEventFn<AnalyticsPayload> = ({
25
+ id: eventId,
26
+ payload: eventData,
27
+ }) => {
28
+ // runtime check just in case - shouldn't be needed due to TS
29
+ if (!eventID(eventId).isValid()) {
30
+ throw new Error(`Implementation error: Invalid event id: ${eventId}`);
31
+ }
32
+
33
+ const uniqueEventId = crypto.randomUUID();
34
+
35
+ const data = (() => {
36
+ if (!eventData) {
37
+ return undefined;
38
+ }
39
+
40
+ return toSnakeCaseDeep((eventData as unknown) ?? {});
41
+ })();
42
+
43
+ const event = {
44
+ id: `editor_${eventId}`,
45
+ data: {
46
+ ...data,
47
+ // eslint-disable-next-line camelcase
48
+ job_id: context.jobId,
49
+ // eslint-disable-next-line camelcase
50
+ event_id: uniqueEventId,
51
+ timestamp: new Date().toISOString(),
52
+ },
53
+ };
54
+
55
+ return event;
56
+ };
57
+
58
+ return { newEvent };
59
+ },
60
+ /**
61
+ * This function creates an event sender which will use an internal implementation of an adapter
62
+ * to forward the event's data to an external third-party provider
63
+ * @returns
64
+ */
65
+ eventSender: (adapter: AnalyticsAdapter) => {
66
+ const send = (event: AnalyticsPayload) => {
67
+ adapter.sendEvent({
68
+ event,
69
+ idParser: eventID(event.id).parse,
70
+ metadata: METADATA,
71
+ });
72
+ };
73
+
74
+ return { send };
75
+ },
76
+ });
@@ -0,0 +1,2 @@
1
+ export * from "./analytics";
2
+ export type { CreateEventFn, AnalyticsAdapter } from "./types";
@@ -0,0 +1,129 @@
1
+ /**
2
+ * This file was automatically generated by json-schema-to-typescript.
3
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
4
+ * and run json-schema-to-typescript to regenerate this file.
5
+ */
6
+
7
+ export type EmptyPayload = null;
8
+ export type NodeCreated_Base = NodeCreated_Common & NodeOrComponent;
9
+ export type NodeCreated_WithOptionalConnectedTo = NodeCreated_Common &
10
+ NodeOrComponent & {
11
+ connectedTo?: ConnectedTo_Basic;
12
+ };
13
+ export type NodeCreated_WithOptionalConnectedToPorts = NodeCreated_Common &
14
+ NodeOrComponent & {
15
+ connectedTo?: ConnectedTo_WithPorts;
16
+ };
17
+
18
+ export interface AnalyticsEventSchema {
19
+ version: "v1.0";
20
+ events: {
21
+ kai_prompted: {
22
+ kaiqa_button_: EmptyPayload;
23
+ kaibuild_button_: EmptyPayload;
24
+ qam_button_: KaiPromptedPayload;
25
+ };
26
+ layouteditor_opened: {
27
+ canvas_ctxmenu_openlayouteditor: EmptyPayload;
28
+ keyboard_shortcut_openlayouteditor: EmptyPayload;
29
+ wftoolbar_button_openlayouteditor: EmptyPayload;
30
+ };
31
+ annotation_created: {
32
+ canvas_ctxmenu_newannotation: EmptyPayload;
33
+ canvas_ctxmenu_kaiexplain: EmptyPayload;
34
+ };
35
+ workflow_saved: {
36
+ wftoolbar_button_save: WorkflowSavedPayload;
37
+ keyboard_shortcut_savewf: WorkflowSavedPayload;
38
+ };
39
+ connection_created: {
40
+ port_dragdrop_fwd: ConnectionCreatedPayload;
41
+ port_dragdrop_bwd: ConnectionCreatedPayload;
42
+ keyboard_shortcut_connectnodes: EmptyPayload;
43
+ keyboard_shortcut_connectflowvar: EmptyPayload;
44
+ canvas_ctxmenu_connectnodes: EmptyPayload;
45
+ canvas_ctxmenu_connectflowvar: EmptyPayload;
46
+ };
47
+ node_created: {
48
+ noderepo_dragdrop_: NodeCreated_Base;
49
+ noderepo_doubleclick_: NodeCreated_WithOptionalConnectedTo;
50
+ noderepo_keyboard_enter: NodeCreated_WithOptionalConnectedTo;
51
+ explorer_dragdrop_: NodeCreated_Base;
52
+ qam_click_: NodeCreated_WithOptionalConnectedToPorts;
53
+ qam_keyboard_enter: NodeCreated_WithOptionalConnectedToPorts;
54
+ kaiqa_dragdrop_: NodeCreated_Base;
55
+ kaiqa_keyboard_enter: NodeCreated_Base;
56
+ };
57
+ qam_opened: {
58
+ port_dragdrop_fwd: QAMOpened_Payload;
59
+ port_dragdrop_bwd: QAMOpened_Payload;
60
+ canvas_doubleclick_: EmptyPayload;
61
+ keyboard_shortcut_: QAMOpened_PartialPayload;
62
+ canvas_ctxmenu_quickaddnode: EmptyPayload;
63
+ };
64
+ node_searched: {
65
+ noderepo_type_: SearchPayload;
66
+ qam_type_: SearchPayload;
67
+ };
68
+ sidepanel_opened: {
69
+ sidepanel_click_info: EmptyPayload;
70
+ sidepanel_click_noderepo: EmptyPayload;
71
+ sidepanel_click_explorer: EmptyPayload;
72
+ sidepanel_click_kai: EmptyPayload;
73
+ sidepanel_click_monitor: EmptyPayload;
74
+ wftoolbar_dropdownmenu_versionhistory: EmptyPayload;
75
+ };
76
+ action_undone: {
77
+ wftoolbar_button_: EmptyPayload;
78
+ keyboard_shortcut_: EmptyPayload;
79
+ };
80
+ action_redone: {
81
+ wftoolbar_button_: EmptyPayload;
82
+ keyboard_shortcut_: EmptyPayload;
83
+ };
84
+ };
85
+ }
86
+ export interface KaiPromptedPayload {
87
+ nodeFactoryId?: string;
88
+ nodeType?: "node" | "component" | "metanode";
89
+ }
90
+ export interface WorkflowSavedPayload {
91
+ isAutosyncEnabled: boolean;
92
+ }
93
+ export interface ConnectionCreatedPayload {
94
+ fromNode: ConnectionCreated_NodePayload;
95
+ toNode: ConnectionCreated_NodePayload;
96
+ }
97
+ export interface ConnectionCreated_NodePayload {
98
+ nodeType: "node" | "component" | "metanode";
99
+ nodePortIndex: number;
100
+ nodePortId: string;
101
+ nodeFactoryId?: string;
102
+ }
103
+ export interface NodeCreated_Common {
104
+ nodeType: "node" | "component";
105
+ }
106
+ export interface NodeOrComponent {
107
+ nodeFactoryId?: string;
108
+ nodeHubId?: string;
109
+ }
110
+ export interface ConnectedTo_Basic {
111
+ nodeType: "node" | "component" | "metanode";
112
+ nodeFactoryId: string;
113
+ }
114
+ export interface ConnectedTo_WithPorts {
115
+ nodeType: "node" | "component" | "metanode";
116
+ nodeFactoryId: string;
117
+ nodePortIndex?: number;
118
+ nodePortId?: string;
119
+ }
120
+ export interface QAMOpened_Payload {
121
+ connectedTo: ConnectedTo_WithPorts;
122
+ }
123
+ export interface QAMOpened_PartialPayload {
124
+ connectedTo?: ConnectedTo_WithPorts;
125
+ }
126
+ export interface SearchPayload {
127
+ repoType: "node" | "component";
128
+ keyword: string;
129
+ }
@@ -0,0 +1,472 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "inmemory://schemas/analytics-events.json",
4
+ "title": "AnalyticsEventSchema",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "events"],
8
+ "properties": {
9
+ "version": {
10
+ "$comment": "Version of this document",
11
+ "type": "string",
12
+ "const": "v1.0"
13
+ },
14
+ "events": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": [
18
+ "kai_prompted",
19
+ "layouteditor_opened",
20
+ "annotation_created",
21
+ "workflow_saved",
22
+ "connection_created",
23
+ "node_created",
24
+ "qam_opened",
25
+ "node_searched",
26
+ "sidepanel_opened",
27
+ "action_undone",
28
+ "action_redone"
29
+ ],
30
+ "properties": {
31
+ "kai_prompted": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["kaiqa_button_", "kaibuild_button_", "qam_button_"],
35
+ "properties": {
36
+ "kaiqa_button_": {
37
+ "$ref": "#/definitions/EmptyPayload"
38
+ },
39
+ "kaibuild_button_": {
40
+ "$ref": "#/definitions/EmptyPayload"
41
+ },
42
+ "qam_button_": {
43
+ "$ref": "#/definitions/KaiPromptedPayload"
44
+ }
45
+ }
46
+ },
47
+ "layouteditor_opened": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "required": [
51
+ "canvas_ctxmenu_openlayouteditor",
52
+ "keyboard_shortcut_openlayouteditor",
53
+ "wftoolbar_button_openlayouteditor"
54
+ ],
55
+ "properties": {
56
+ "canvas_ctxmenu_openlayouteditor": {
57
+ "$ref": "#/definitions/EmptyPayload"
58
+ },
59
+ "keyboard_shortcut_openlayouteditor": {
60
+ "$ref": "#/definitions/EmptyPayload"
61
+ },
62
+ "wftoolbar_button_openlayouteditor": {
63
+ "$ref": "#/definitions/EmptyPayload"
64
+ }
65
+ }
66
+ },
67
+ "annotation_created": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": [
71
+ "canvas_ctxmenu_newannotation",
72
+ "canvas_ctxmenu_kaiexplain"
73
+ ],
74
+ "properties": {
75
+ "canvas_ctxmenu_newannotation": {
76
+ "$ref": "#/definitions/EmptyPayload"
77
+ },
78
+ "canvas_ctxmenu_kaiexplain": {
79
+ "$ref": "#/definitions/EmptyPayload"
80
+ }
81
+ }
82
+ },
83
+ "workflow_saved": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "required": ["wftoolbar_button_save", "keyboard_shortcut_savewf"],
87
+ "properties": {
88
+ "wftoolbar_button_save": {
89
+ "$ref": "#/definitions/WorkflowSavedPayload"
90
+ },
91
+ "keyboard_shortcut_savewf": {
92
+ "$ref": "#/definitions/WorkflowSavedPayload"
93
+ }
94
+ }
95
+ },
96
+ "connection_created": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": [
100
+ "port_dragdrop_fwd",
101
+ "port_dragdrop_bwd",
102
+ "keyboard_shortcut_connectnodes",
103
+ "keyboard_shortcut_connectflowvar",
104
+ "canvas_ctxmenu_connectnodes",
105
+ "canvas_ctxmenu_connectflowvar"
106
+ ],
107
+ "properties": {
108
+ "port_dragdrop_fwd": {
109
+ "$ref": "#/definitions/ConnectionCreatedPayload"
110
+ },
111
+ "port_dragdrop_bwd": {
112
+ "$ref": "#/definitions/ConnectionCreatedPayload"
113
+ },
114
+ "keyboard_shortcut_connectnodes": {
115
+ "$ref": "#/definitions/EmptyPayload"
116
+ },
117
+ "keyboard_shortcut_connectflowvar": {
118
+ "$ref": "#/definitions/EmptyPayload"
119
+ },
120
+ "canvas_ctxmenu_connectnodes": {
121
+ "$ref": "#/definitions/EmptyPayload"
122
+ },
123
+ "canvas_ctxmenu_connectflowvar": {
124
+ "$ref": "#/definitions/EmptyPayload"
125
+ }
126
+ }
127
+ },
128
+ "node_created": {
129
+ "type": "object",
130
+ "additionalProperties": false,
131
+ "required": [
132
+ "noderepo_dragdrop_",
133
+ "noderepo_doubleclick_",
134
+ "noderepo_keyboard_enter",
135
+ "explorer_dragdrop_",
136
+ "qam_click_",
137
+ "qam_keyboard_enter",
138
+ "kaiqa_dragdrop_",
139
+ "kaiqa_keyboard_enter"
140
+ ],
141
+ "properties": {
142
+ "noderepo_dragdrop_": {
143
+ "$ref": "#/definitions/NodeCreated_Base"
144
+ },
145
+ "noderepo_doubleclick_": {
146
+ "$ref": "#/definitions/NodeCreated_WithOptionalConnectedTo"
147
+ },
148
+ "noderepo_keyboard_enter": {
149
+ "$ref": "#/definitions/NodeCreated_WithOptionalConnectedTo"
150
+ },
151
+ "explorer_dragdrop_": {
152
+ "$ref": "#/definitions/NodeCreated_Base"
153
+ },
154
+ "qam_click_": {
155
+ "$ref": "#/definitions/NodeCreated_WithOptionalConnectedToPorts"
156
+ },
157
+ "qam_keyboard_enter": {
158
+ "$ref": "#/definitions/NodeCreated_WithOptionalConnectedToPorts"
159
+ },
160
+ "kaiqa_dragdrop_": {
161
+ "$ref": "#/definitions/NodeCreated_Base"
162
+ },
163
+ "kaiqa_keyboard_enter": {
164
+ "$ref": "#/definitions/NodeCreated_Base"
165
+ }
166
+ }
167
+ },
168
+ "qam_opened": {
169
+ "type": "object",
170
+ "additionalProperties": false,
171
+ "required": [
172
+ "port_dragdrop_fwd",
173
+ "port_dragdrop_bwd",
174
+ "canvas_doubleclick_",
175
+ "keyboard_shortcut_",
176
+ "canvas_ctxmenu_quickaddnode"
177
+ ],
178
+ "properties": {
179
+ "port_dragdrop_fwd": {
180
+ "$ref": "#/definitions/QAMOpened_Payload"
181
+ },
182
+ "port_dragdrop_bwd": {
183
+ "$ref": "#/definitions/QAMOpened_Payload"
184
+ },
185
+ "canvas_doubleclick_": {
186
+ "$ref": "#/definitions/EmptyPayload"
187
+ },
188
+ "keyboard_shortcut_": {
189
+ "$ref": "#/definitions/QAMOpened_PartialPayload"
190
+ },
191
+ "canvas_ctxmenu_quickaddnode": {
192
+ "$ref": "#/definitions/EmptyPayload"
193
+ }
194
+ }
195
+ },
196
+ "node_searched": {
197
+ "type": "object",
198
+ "additionalProperties": false,
199
+ "required": ["noderepo_type_", "qam_type_"],
200
+ "properties": {
201
+ "noderepo_type_": {
202
+ "$ref": "#/definitions/SearchPayload"
203
+ },
204
+ "qam_type_": {
205
+ "$ref": "#/definitions/SearchPayload"
206
+ }
207
+ }
208
+ },
209
+ "sidepanel_opened": {
210
+ "type": "object",
211
+ "additionalProperties": false,
212
+ "required": [
213
+ "sidepanel_click_info",
214
+ "sidepanel_click_noderepo",
215
+ "sidepanel_click_explorer",
216
+ "sidepanel_click_kai",
217
+ "sidepanel_click_monitor",
218
+ "wftoolbar_dropdownmenu_versionhistory"
219
+ ],
220
+ "properties": {
221
+ "sidepanel_click_info": {
222
+ "$ref": "#/definitions/EmptyPayload"
223
+ },
224
+ "sidepanel_click_noderepo": {
225
+ "$ref": "#/definitions/EmptyPayload"
226
+ },
227
+ "sidepanel_click_explorer": {
228
+ "$ref": "#/definitions/EmptyPayload"
229
+ },
230
+ "sidepanel_click_kai": {
231
+ "$ref": "#/definitions/EmptyPayload"
232
+ },
233
+ "sidepanel_click_monitor": {
234
+ "$ref": "#/definitions/EmptyPayload"
235
+ },
236
+ "wftoolbar_dropdownmenu_versionhistory": {
237
+ "$ref": "#/definitions/EmptyPayload"
238
+ }
239
+ }
240
+ },
241
+ "action_undone": {
242
+ "type": "object",
243
+ "additionalProperties": false,
244
+ "required": ["wftoolbar_button_", "keyboard_shortcut_"],
245
+ "properties": {
246
+ "wftoolbar_button_": {
247
+ "$ref": "#/definitions/EmptyPayload"
248
+ },
249
+ "keyboard_shortcut_": {
250
+ "$ref": "#/definitions/EmptyPayload"
251
+ }
252
+ }
253
+ },
254
+ "action_redone": {
255
+ "type": "object",
256
+ "additionalProperties": false,
257
+ "required": ["wftoolbar_button_", "keyboard_shortcut_"],
258
+ "properties": {
259
+ "wftoolbar_button_": {
260
+ "$ref": "#/definitions/EmptyPayload"
261
+ },
262
+ "keyboard_shortcut_": {
263
+ "$ref": "#/definitions/EmptyPayload"
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
269
+ },
270
+ "definitions": {
271
+ "EmptyPayload": {
272
+ "type": "null",
273
+ "additionalProperties": false,
274
+ "maxProperties": 0
275
+ },
276
+ "WorkflowSavedPayload": {
277
+ "type": "object",
278
+ "additionalProperties": false,
279
+ "required": ["isAutosyncEnabled"],
280
+ "properties": {
281
+ "isAutosyncEnabled": {
282
+ "type": "boolean"
283
+ }
284
+ }
285
+ },
286
+ "ConnectionCreated_NodePayload": {
287
+ "type": "object",
288
+ "additionalProperties": false,
289
+ "required": ["nodeType", "nodePortIndex", "nodePortId"],
290
+ "properties": {
291
+ "nodeType": {
292
+ "type": "string",
293
+ "enum": ["node", "component", "metanode"]
294
+ },
295
+ "nodePortIndex": {
296
+ "type": "number"
297
+ },
298
+ "nodePortId": {
299
+ "type": "string"
300
+ },
301
+ "nodeFactoryId": {
302
+ "type": "string"
303
+ }
304
+ }
305
+ },
306
+ "ConnectionCreatedPayload": {
307
+ "type": "object",
308
+ "additionalProperties": false,
309
+ "required": ["fromNode", "toNode"],
310
+ "properties": {
311
+ "fromNode": {
312
+ "$ref": "#/definitions/ConnectionCreated_NodePayload"
313
+ },
314
+ "toNode": {
315
+ "$ref": "#/definitions/ConnectionCreated_NodePayload"
316
+ }
317
+ }
318
+ },
319
+ "KaiPromptedPayload": {
320
+ "type": "object",
321
+ "additionalProperties": false,
322
+ "properties": {
323
+ "nodeFactoryId": {
324
+ "type": "string"
325
+ },
326
+ "nodeType": {
327
+ "type": "string",
328
+ "enum": ["node", "component", "metanode"]
329
+ }
330
+ }
331
+ },
332
+ "NodeOrComponent": {
333
+ "type": "object",
334
+ "additionalProperties": false,
335
+ "properties": {
336
+ "nodeFactoryId": {
337
+ "type": "string",
338
+ "$comment": "asdasd"
339
+ },
340
+ "nodeHubId": {
341
+ "type": "string"
342
+ }
343
+ }
344
+ },
345
+ "NodeCreated_Common": {
346
+ "type": "object",
347
+ "additionalProperties": false,
348
+ "required": ["nodeType"],
349
+ "properties": {
350
+ "nodeType": {
351
+ "type": "string",
352
+ "enum": ["node", "component"]
353
+ }
354
+ }
355
+ },
356
+ "ConnectedTo_Basic": {
357
+ "type": "object",
358
+ "additionalProperties": false,
359
+ "required": ["nodeType", "nodeFactoryId"],
360
+ "properties": {
361
+ "nodeType": {
362
+ "type": "string",
363
+ "enum": ["node", "component", "metanode"]
364
+ },
365
+ "nodeFactoryId": {
366
+ "type": "string"
367
+ }
368
+ }
369
+ },
370
+ "ConnectedTo_WithPorts": {
371
+ "type": "object",
372
+ "additionalProperties": false,
373
+ "required": ["nodeType", "nodeFactoryId"],
374
+ "properties": {
375
+ "nodeType": {
376
+ "type": "string",
377
+ "enum": ["node", "component", "metanode"]
378
+ },
379
+ "nodeFactoryId": {
380
+ "type": "string"
381
+ },
382
+ "nodePortIndex": {
383
+ "type": "number"
384
+ },
385
+ "nodePortId": {
386
+ "type": "string"
387
+ }
388
+ }
389
+ },
390
+ "NodeCreated_Base": {
391
+ "allOf": [
392
+ {
393
+ "$ref": "#/definitions/NodeCreated_Common"
394
+ },
395
+ {
396
+ "$ref": "#/definitions/NodeOrComponent"
397
+ }
398
+ ]
399
+ },
400
+ "NodeCreated_WithOptionalConnectedTo": {
401
+ "allOf": [
402
+ {
403
+ "$ref": "#/definitions/NodeCreated_Common"
404
+ },
405
+ {
406
+ "$ref": "#/definitions/NodeOrComponent"
407
+ },
408
+ {
409
+ "type": "object",
410
+ "additionalProperties": false,
411
+ "properties": {
412
+ "connectedTo": {
413
+ "$ref": "#/definitions/ConnectedTo_Basic"
414
+ }
415
+ }
416
+ }
417
+ ]
418
+ },
419
+ "NodeCreated_WithOptionalConnectedToPorts": {
420
+ "allOf": [
421
+ {
422
+ "$ref": "#/definitions/NodeCreated_Common"
423
+ },
424
+ {
425
+ "$ref": "#/definitions/NodeOrComponent"
426
+ },
427
+ {
428
+ "type": "object",
429
+ "additionalProperties": false,
430
+ "properties": {
431
+ "connectedTo": {
432
+ "$ref": "#/definitions/ConnectedTo_WithPorts"
433
+ }
434
+ }
435
+ }
436
+ ]
437
+ },
438
+ "QAMOpened_Payload": {
439
+ "type": "object",
440
+ "additionalProperties": false,
441
+ "required": ["connectedTo"],
442
+ "properties": {
443
+ "connectedTo": {
444
+ "$ref": "#/definitions/ConnectedTo_WithPorts"
445
+ }
446
+ }
447
+ },
448
+ "QAMOpened_PartialPayload": {
449
+ "type": "object",
450
+ "additionalProperties": false,
451
+ "properties": {
452
+ "connectedTo": {
453
+ "$ref": "#/definitions/ConnectedTo_WithPorts"
454
+ }
455
+ }
456
+ },
457
+ "SearchPayload": {
458
+ "type": "object",
459
+ "additionalProperties": false,
460
+ "required": ["repoType", "keyword"],
461
+ "properties": {
462
+ "repoType": {
463
+ "type": "string",
464
+ "enum": ["node", "component"]
465
+ },
466
+ "keyword": {
467
+ "type": "string"
468
+ }
469
+ }
470
+ }
471
+ }
472
+ }
@@ -0,0 +1,62 @@
1
+ import type { AnalyticsEventSchema } from "./schema/schema";
2
+
3
+ export const SEPARATOR = "::";
4
+ type Separator = typeof SEPARATOR;
5
+
6
+ type EventDefinitions = AnalyticsEventSchema["events"];
7
+
8
+ /**
9
+ * Group names (top-level keys in `events`, e.g. "kai_prompted").
10
+ */
11
+ type GroupName = keyof EventDefinitions;
12
+
13
+ /**
14
+ * Event names in the flattened "group::event" form. This makes event calls
15
+ * single keys using the `::` separator (for example "kai_prompted::kaiqa_button_prompt").
16
+ */
17
+ export type EventNames = {
18
+ [G in GroupName]: `${G}${Separator}${keyof EventDefinitions[G] & string}`;
19
+ }[GroupName];
20
+
21
+ export type Metadata = Omit<AnalyticsEventSchema, "events">;
22
+
23
+ export type Context = { jobId: string };
24
+
25
+ /**
26
+ * Resolve the payload type for a flattened event name like
27
+ * "group::eventKey". If the referenced payload is `null`, the args are
28
+ * just the type; otherwise a payload must be provided.
29
+ */
30
+ type PayloadFor<KN extends EventNames> =
31
+ KN extends `${infer G}${Separator}${infer E}`
32
+ ? G extends GroupName
33
+ ? E extends keyof EventDefinitions[G]
34
+ ? EventDefinitions[G][E]
35
+ : never
36
+ : never
37
+ : never;
38
+
39
+ type EventArgs<K extends EventNames> =
40
+ PayloadFor<K> extends null
41
+ ? { id: K; payload?: never }
42
+ : { id: K; payload: PayloadFor<K> };
43
+
44
+ export type AnalyticsPayload = {
45
+ id: string;
46
+ data: unknown;
47
+ };
48
+
49
+ export type CreateEventFn<TReturn = void> = <K extends EventNames>(
50
+ args: EventArgs<K>,
51
+ ) => TReturn;
52
+
53
+ export interface AnalyticsAdapter {
54
+ /**
55
+ * Function to send the event according to the API the adapter is implementing
56
+ */
57
+ sendEvent: (params: {
58
+ idParser: (id: string) => { category: string; action: string };
59
+ event: AnalyticsPayload;
60
+ metadata: Metadata;
61
+ }) => void;
62
+ }
@@ -0,0 +1,35 @@
1
+ import { SEPARATOR } from "../types";
2
+
3
+ const isValid = (id: string) => id.includes(SEPARATOR);
4
+
5
+ /**
6
+ * Parses the given event id, splitting it by a known separator. Each id is expected to have
7
+ * two components to it: The event category and a specific action within that category
8
+ * @returns
9
+ * @throws when id format is invalid
10
+ */
11
+ const parse = (id: string): { category: string; action: string } => {
12
+ if (!isValid(id)) {
13
+ throw new Error(`Cannot parse event id. Invalid format found: ${id}`);
14
+ }
15
+
16
+ const out = id.split(SEPARATOR);
17
+
18
+ if (out.length !== 2) {
19
+ throw new Error(`Cannot parse event id. Invalid format found: ${id}`);
20
+ }
21
+
22
+ const [category, action] = out;
23
+ return { category, action };
24
+ };
25
+
26
+ export const eventID = (id: string) => ({
27
+ isValid: () => isValid(id),
28
+ /**
29
+ * Parses the given event id, splitting it by a known separator. Each id is expected to have
30
+ * two components to it: The event category and a specific action within that category
31
+ * @returns
32
+ * @throws when id format is invalid
33
+ */
34
+ parse: () => parse(id),
35
+ });
@@ -0,0 +1,33 @@
1
+ // eslint-disable-next-line depend/ban-dependencies
2
+ import { snakeCase } from "lodash-es";
3
+
4
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
5
+ return (
6
+ typeof value === "object" &&
7
+ value !== null &&
8
+ Object.getPrototypeOf(value) === Object.prototype
9
+ );
10
+ };
11
+
12
+ /**
13
+ * Deeply converts all properties in the given object from camelCase to snake_case.
14
+ */
15
+ export const toSnakeCaseDeep = (object: Partial<Record<string, unknown>>) =>
16
+ Object.entries(object).reduce(
17
+ (acc, [rawKey, rawValue]) => {
18
+ const key = rawKey.toString();
19
+
20
+ if (isPlainObject(rawValue)) {
21
+ acc[snakeCase(key)] = toSnakeCaseDeep(rawValue);
22
+ } else if (Array.isArray(rawValue)) {
23
+ acc[snakeCase(key)] = rawValue.map((v) =>
24
+ isPlainObject(v) ? toSnakeCaseDeep(v) : v,
25
+ );
26
+ } else {
27
+ acc[snakeCase(key)] = rawValue;
28
+ }
29
+
30
+ return acc;
31
+ },
32
+ {} as Record<string, unknown>,
33
+ );
@@ -0,0 +1,123 @@
1
+ # KNIME® Hub Auth Utils
2
+
3
+ The auth utils can be used to implement authentication in the hub. The idea is to use them in conjunction with the [knime-hub-auth-rely] service. Please check the service docs for detailed infos about the authentication flow.
4
+
5
+ [knime-hub-auth-rely]: https://github.com/knime/knime-hub-auth-rely
6
+
7
+ ## Common usage pattern
8
+
9
+ First you need to setup the auth refresher. This will make sure that the auth identity information
10
+ is fetched on critical authenticated paths. Normally, this means as part of a `vue-router` route guard
11
+ or a `Nuxt` middleware.
12
+
13
+ ```ts
14
+ /// router.ts
15
+ import { authUtils } from "@knime/hub-features";
16
+
17
+ const myMiddleware = (): NavigationGuard => {
18
+ // 1. First we create the refresher. This returns the function that
19
+ // schedules running the token refresher periodically
20
+ const authRefresher = authUtils.createAuthRefresher({
21
+ // `getIdentity` is just a fetch function that hits the API to get the
22
+ // account information; in a nutshell a user `id` and a `name`
23
+ getIdentity,
24
+ });
25
+
26
+ // 2. Here we're now returning the _actual_ navigation guard
27
+ return async () => {
28
+ try {
29
+ await authRefresher();
30
+ return true; // allow navigation
31
+ } catch (error) {
32
+ consola.error("Navigation middleware error", error);
33
+ return false; // cancel navigation
34
+ }
35
+ };
36
+ };
37
+
38
+ const router = createRouter({
39
+ history: createWebHistory(base),
40
+ routes: [
41
+ {
42
+ path: "/",
43
+ name: "Home",
44
+ component: HomeRouteComponent,
45
+ // 3. Simply setup your middleware for usage
46
+ beforeEnter: [myMiddleware()],
47
+
48
+ // 4. A simpler usage could have been
49
+ // beforeEnter: [authUtils.createAuthRefresher({ getIdentity })],
50
+ },
51
+ ],
52
+ });
53
+ ```
54
+
55
+ ### Handle auth errors
56
+
57
+ Another common pattern you'll need is to react to authentication errors (401/403) on your requests. There's different ways
58
+ to do this depending on the setup, but it usually revolves around setting up some HTTP request interceptor that runs some
59
+ code upon receveing a 401 (and optionally sometimes a 403).
60
+
61
+ Below is an example of an `ofetch` interceptor and how you would use these utils for such a case
62
+
63
+ ```ts
64
+ // interceptor.ts
65
+ import { authUtils } from "@knime/hub-features";
66
+
67
+ export const unauthorizedInterceptor: HttpInterceptor = {
68
+ response: {
69
+ onError: ({ response }) => {
70
+ if (response?.status === 401) {
71
+ // This function performs the redirection automatically.
72
+ authUtils.client.navigateToLogin({ wdywtg: window.location.href });
73
+
74
+ throw new Error("Auth failure");
75
+ }
76
+ },
77
+ },
78
+ };
79
+ ```
80
+
81
+ However, in the above example, the usage of `authUtils.client.navigateToLogin` is client-side code, which uses the `window` object to
82
+ redirect. Therefore, this doesn't work on SSR. If you need a server-side redirect, or if you want more control of _how_ you redirect (e.g using Nuxt's `navigateTo` function)
83
+ you can instead call another util which returns the login url and you can perform the redirection on your own:
84
+
85
+ ```ts
86
+ // some-file.ts
87
+
88
+ import { authUtils } from "@knime/hub-features";
89
+
90
+ const someHandler = () => {
91
+ // ...
92
+ const loginPath = authUtils.paths.login({ wdywtg: to.fullPath });
93
+ return navigateTo(loginPath, {
94
+ external: true,
95
+ redirectCode: TEMPORARY_REDIRECT,
96
+ });
97
+
98
+ // ...
99
+ };
100
+ ```
101
+
102
+ ### Accessing auth state
103
+
104
+ With the middleware in place, you can now reference the auth state in your app by making use of the provided stateful composable:
105
+
106
+ ```ts
107
+ /// some-file.ts
108
+ import { authUtils } from "@knime/hub-features";
109
+
110
+ const { loggedInUser, isLoggedIn } = authUtils.useAuthState();
111
+ ```
112
+
113
+ This state is immutable and is owned and controlled by the auth refresher internally.
114
+
115
+ ### Stop refreshing the auth token
116
+
117
+ In addition to the route middleware setup, you might run into cases where you want to stop the refresher manually. For this, simply import the corresponding function and call it:
118
+
119
+ ```ts
120
+ import { authUtils } from "@knime/hub-features";
121
+
122
+ authUtils.stopTokenRefresh();
123
+ ```
@@ -0,0 +1,18 @@
1
+ import { logger } from "./logger";
2
+ import { buildLoginPath, buildLogoutPath } from "./shared";
3
+
4
+ /**
5
+ * Navigates to the login path. This function does not work on SSR
6
+ */
7
+ export const navigateToLogin = ({ wdywtg }: { wdywtg?: string } = {}) => {
8
+ logger().debug("Navigating to login");
9
+ window.location.href = buildLoginPath({ wdywtg }); // NOSONAR - intended window usage
10
+ };
11
+
12
+ /**
13
+ * Navigates to the logout path. This function does not work on SSR
14
+ */
15
+ export const navigateToLogout = ({ wdywtg }: { wdywtg?: string } = {}) => {
16
+ logger().debug("Navigating to logout");
17
+ window.location.href = buildLogoutPath({ wdywtg }); // NOSONAR - intended window usage
18
+ };
@@ -0,0 +1,61 @@
1
+ import * as client from "./client";
2
+ import { logger } from "./logger";
3
+ import { startRefresher, stopRefresher } from "./refresher";
4
+ import { buildLoginPath, buildLogoutPath } from "./shared";
5
+ import type { AuthRefresher } from "./types";
6
+ import { setLoggedInUser, useAuthState } from "./useAuthState";
7
+
8
+ /**
9
+ * This function does two main things:
10
+ * 1. Fetch the user identity and store it via the auth composable.
11
+ * 2. Start the background refresher which will renew tokens according to the
12
+ * auth service TTL.
13
+ *
14
+ * Typical usage is from a route middleware for routes that require
15
+ * authentication. See README for a more in-depth example usage
16
+ *
17
+ * @param options AuthRefresher
18
+ * @returns a function that, when called, will load the identity (if needed)
19
+ * and start the token refresher. The returned function will throw if the
20
+ * underlying identity fetch fails.
21
+ */
22
+ function createAuthRefresher(options: AuthRefresher) {
23
+ return async () => {
24
+ const { isLoggedIn } = useAuthState();
25
+
26
+ if (!isLoggedIn.value) {
27
+ try {
28
+ const identity = await options.getIdentity();
29
+
30
+ if (identity) {
31
+ logger().info("Fetched user identity", {
32
+ id: identity.id,
33
+ name: identity.name,
34
+ });
35
+
36
+ setLoggedInUser(identity);
37
+ startRefresher({ onRefreshComplete: options.onRefreshComplete });
38
+ } else {
39
+ logger().error("Logged in, but could not retrieve logged in user");
40
+ }
41
+ } catch (e) {
42
+ logger().error(
43
+ "Not logged in, http request interceptor should redirect to login",
44
+ e,
45
+ );
46
+ throw e;
47
+ }
48
+ }
49
+ };
50
+ }
51
+
52
+ export const authUtils = {
53
+ createAuthRefresher,
54
+ stopTokenRefresh: stopRefresher,
55
+ useAuthState,
56
+ /**
57
+ * These utilities don't work in SSR. You can leverage the `paths` helpers to redirect manually
58
+ */
59
+ client,
60
+ paths: { login: buildLoginPath, logout: buildLogoutPath },
61
+ };
@@ -0,0 +1 @@
1
+ export const logger = () => consola.withTag("Auth Utils");
@@ -0,0 +1,65 @@
1
+ import { navigateToLogout } from "./client";
2
+ import { logger } from "./logger";
3
+ import { AUTH_SERVICE_PATH } from "./shared";
4
+ import type { AuthRefresher } from "./types";
5
+
6
+ type Options = Pick<AuthRefresher, "onRefreshComplete">;
7
+
8
+ const REFRESH_BUFFER = 10;
9
+ /**
10
+ * Subtracts a random noise value from the ttl to refresh the token before it actually expires.
11
+ *
12
+ * @param {number} ttlSeconds - time-to-live (TTL) in seconds
13
+ */
14
+ const subtractRandomNoise = (ttlSeconds: number) => {
15
+ const randomBuffer = Math.random() * (REFRESH_BUFFER / 2); // NOSONAR using random numbers is safe here
16
+ return Math.floor((ttlSeconds - REFRESH_BUFFER - randomBuffer) * 1000);
17
+ };
18
+
19
+ let timeout: number | NodeJS.Timeout;
20
+
21
+ /**
22
+ * Start proactively refreshing the token before it expires.
23
+ */
24
+ export const startRefresher = (options: Options = {}) => {
25
+ logger().debug("Starting token refresher");
26
+
27
+ globalThis.clearTimeout(timeout);
28
+
29
+ const fetchData = async () => {
30
+ try {
31
+ const response = await fetch(`${AUTH_SERVICE_PATH}/refresh`);
32
+
33
+ if (!response.ok) {
34
+ throw new Error(`Error during auth refresh: ${response.status}`);
35
+ }
36
+
37
+ options?.onRefreshComplete?.();
38
+ const { expiry } = await response.json();
39
+
40
+ const msUntilRefresh = subtractRandomNoise(expiry);
41
+
42
+ logger().debug(
43
+ `Token is valid. Expires in ${expiry}s, refreshing in ${msUntilRefresh / 1000}s`,
44
+ );
45
+
46
+ timeout = globalThis.setTimeout(() => {
47
+ // eslint-disable-next-line no-void
48
+ void fetchData();
49
+ }, msUntilRefresh);
50
+ } catch (error) {
51
+ logger().warn(`Token refresh failed: ${error}, logging out`);
52
+ navigateToLogout({ wdywtg: "/?authError=refresh" });
53
+ }
54
+ };
55
+
56
+ // eslint-disable-next-line no-void
57
+ void fetchData();
58
+ };
59
+
60
+ export const stopRefresher = () => {
61
+ if (timeout !== undefined) {
62
+ logger().info("Stopping auth refresh timer");
63
+ globalThis.clearTimeout(timeout);
64
+ }
65
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Adds a "where do you want to go" `wdywtg` query param to the url. This
3
+ * will take the user to the URL contained within that param after the auth flow completes
4
+ */
5
+ const addWdywtg = (url: string, wdywtg?: string) => {
6
+ if (wdywtg) {
7
+ const decodedWdywtg = decodeURIComponent(wdywtg);
8
+
9
+ // we decode an already-encoded wdywtg so that we can properly encode spaces
10
+ if (decodedWdywtg !== wdywtg) {
11
+ wdywtg = decodedWdywtg;
12
+ }
13
+
14
+ url += `?wdywtg=${encodeURIComponent(wdywtg)}`;
15
+ }
16
+ return url;
17
+ };
18
+
19
+ export const AUTH_SERVICE_PATH = "/_/auth";
20
+
21
+ export const buildLoginPath = ({ wdywtg }: { wdywtg?: string } = {}) =>
22
+ addWdywtg(`${AUTH_SERVICE_PATH}/login`, wdywtg);
23
+
24
+ export const buildLogoutPath = ({ wdywtg }: { wdywtg?: string } = {}) =>
25
+ addWdywtg(`${AUTH_SERVICE_PATH}/logout`, wdywtg);
@@ -0,0 +1,12 @@
1
+ export type UserIdentity = { id: string; name: string };
2
+
3
+ export type AuthRefresher = {
4
+ /**
5
+ * Fetcher function to obtain the basic user identity information
6
+ */
7
+ getIdentity: () => Promise<UserIdentity>;
8
+ /**
9
+ * Callback that runs after each completion of an auth token refresh
10
+ */
11
+ onRefreshComplete?: () => unknown;
12
+ };
@@ -0,0 +1,19 @@
1
+ import { computed, readonly, shallowRef } from "vue";
2
+
3
+ import type { UserIdentity } from "./types";
4
+
5
+ // Value is cached in the module scope
6
+ const loggedInUser = shallowRef<UserIdentity | null>(null);
7
+
8
+ export const setLoggedInUser = (user: UserIdentity) => {
9
+ loggedInUser.value = user;
10
+ };
11
+
12
+ export const useAuthState = () => {
13
+ const isLoggedIn = computed(() => Boolean(loggedInUser.value));
14
+
15
+ return {
16
+ loggedInUser: readonly(loggedInUser),
17
+ isLoggedIn,
18
+ };
19
+ };
@@ -35,9 +35,14 @@ export type EmbeddingContext = {
35
35
  */
36
36
  userIdleTimeout?: number;
37
37
  /**
38
- * Whether the embedded application can send analytic and tracking events
38
+ * Configuration for analytics
39
39
  */
40
- enableAnalytics?: boolean;
40
+ analytics?: {
41
+ /**
42
+ * Whether the embedded application can send analytic and tracking events
43
+ */
44
+ enabled: boolean;
45
+ };
41
46
  };
42
47
 
43
48
  type ShowNotificationEvent = {
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./useFileUpload";
3
3
  export * from "./useDownloadArtifact";
4
4
  export * from "./components/avatars";
5
5
  export * from "./embeddingSDK";
6
+ export * from "./authUtils";