@oceanum/oceanumlab 4.6.1 → 4.7.0
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/lib/DatameshUI.d.ts +10 -0
- package/lib/DatameshUI.js +20 -2
- package/lib/DatameshWidget.d.ts +11 -1
- package/lib/DatameshWidget.js +116 -43
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/chatRouter.d.ts +5 -1
- package/lib/chatRouter.js +27 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +15 -0
- package/lib/datameshUiUrl.js +35 -0
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.js +28 -7
- package/package.json +1 -1
- package/schema/datamesh-connect.json +12 -0
- package/src/DatameshUI.tsx +21 -2
- package/src/DatameshWidget.tsx +200 -54
- package/src/__tests__/DatameshWidget.spec.tsx +380 -0
- package/src/__tests__/aiBackend.spec.ts +272 -0
- package/src/__tests__/chatRouter.spec.ts +259 -17
- package/src/__tests__/datameshUiUrl.spec.ts +154 -0
- package/src/aiBackend.ts +150 -0
- package/src/chatRouter.ts +36 -8
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +44 -0
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +38 -10
|
@@ -1,9 +1,33 @@
|
|
|
1
|
+
import type { AuthCommands } from '../aiBackend';
|
|
1
2
|
import { ChatRouter } from '../chatRouter';
|
|
2
3
|
import type { INotebookSnapshot } from '../notebookContext';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** Settings holding `values`; a key not given has no value. */
|
|
6
|
+
const settingsWith = (values: Record<string, unknown>) =>
|
|
7
|
+
({
|
|
8
|
+
get: (key: string) => ({ composite: values[key] })
|
|
9
|
+
}) as unknown as ConstructorParameters<typeof ChatRouter>[0];
|
|
10
|
+
|
|
11
|
+
const settings = settingsWith({ datameshToken: 'a-token' });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A host with these commands, each answering with what its function returns.
|
|
15
|
+
* `enabled` and `visible` are what the registry's queries answer, which is how
|
|
16
|
+
* the sign-in state is read without running anything.
|
|
17
|
+
*/
|
|
18
|
+
const hostCommands = (
|
|
19
|
+
commands: Record<string, () => unknown>,
|
|
20
|
+
{ enabled = [] as string[], visible = [] as string[] } = {}
|
|
21
|
+
) =>
|
|
22
|
+
({
|
|
23
|
+
hasCommand: jest.fn((id: string) => id in commands),
|
|
24
|
+
execute: jest.fn(async (id: string) => commands[id]()),
|
|
25
|
+
isEnabled: jest.fn((id: string) => enabled.includes(id)),
|
|
26
|
+
isVisible: jest.fn((id: string) => visible.includes(id))
|
|
27
|
+
}) as AuthCommands & { execute: jest.Mock };
|
|
28
|
+
|
|
29
|
+
/** Plain JupyterLab: no Oceanum.io sign-in to lend. */
|
|
30
|
+
const noSignIn = hostCommands({});
|
|
7
31
|
|
|
8
32
|
const snapshot = (
|
|
9
33
|
cells: INotebookSnapshot['cells'],
|
|
@@ -12,6 +36,7 @@ const snapshot = (
|
|
|
12
36
|
|
|
13
37
|
let sent: Record<string, unknown> | undefined;
|
|
14
38
|
let sentHeaders: Record<string, string> | undefined;
|
|
39
|
+
let sentUrl: string | undefined;
|
|
15
40
|
|
|
16
41
|
interface IRequestInit {
|
|
17
42
|
body: string;
|
|
@@ -22,9 +47,10 @@ interface IRequestInit {
|
|
|
22
47
|
/** Answer every request with what `response` builds from it. */
|
|
23
48
|
function answering(response: (init: IRequestInit) => unknown): void {
|
|
24
49
|
(globalThis as any).fetch = jest.fn(
|
|
25
|
-
async (
|
|
50
|
+
async (url: string, init: IRequestInit) => {
|
|
26
51
|
sent = JSON.parse(init.body);
|
|
27
52
|
sentHeaders = init.headers;
|
|
53
|
+
sentUrl = url;
|
|
28
54
|
return response(init);
|
|
29
55
|
}
|
|
30
56
|
);
|
|
@@ -33,6 +59,7 @@ function answering(response: (init: IRequestInit) => unknown): void {
|
|
|
33
59
|
beforeEach(() => {
|
|
34
60
|
sent = undefined;
|
|
35
61
|
sentHeaders = undefined;
|
|
62
|
+
sentUrl = undefined;
|
|
36
63
|
answering(() => ({
|
|
37
64
|
ok: true,
|
|
38
65
|
status: 200,
|
|
@@ -82,7 +109,7 @@ function streamed(frames: string[], signal?: AbortSignal, hang = false) {
|
|
|
82
109
|
|
|
83
110
|
describe('ChatRouter: the notebook sent with a request', () => {
|
|
84
111
|
it('is the conversation notebook, code and markdown', async () => {
|
|
85
|
-
const router = new ChatRouter(settings, async () =>
|
|
112
|
+
const router = new ChatRouter(settings, noSignIn, async () =>
|
|
86
113
|
snapshot([
|
|
87
114
|
{ kind: 'code', source: 'x = 1' },
|
|
88
115
|
{ kind: 'markdown', source: '## Notes' },
|
|
@@ -100,7 +127,7 @@ describe('ChatRouter: the notebook sent with a request', () => {
|
|
|
100
127
|
});
|
|
101
128
|
|
|
102
129
|
it('carries the selected cell of that notebook, which a code answer may replace', async () => {
|
|
103
|
-
const router = new ChatRouter(settings, async () =>
|
|
130
|
+
const router = new ChatRouter(settings, noSignIn, async () =>
|
|
104
131
|
snapshot([{ kind: 'code', source: 'df.head()' }], {
|
|
105
132
|
source: 'df.head()',
|
|
106
133
|
isCode: true
|
|
@@ -115,7 +142,7 @@ describe('ChatRouter: the notebook sent with a request', () => {
|
|
|
115
142
|
|
|
116
143
|
it('asks for the notebook on every request, so it follows the conversation', async () => {
|
|
117
144
|
let current = snapshot([{ kind: 'code', source: 'a = 1' }]);
|
|
118
|
-
const router = new ChatRouter(settings, async () => current);
|
|
145
|
+
const router = new ChatRouter(settings, noSignIn, async () => current);
|
|
119
146
|
|
|
120
147
|
await router.route('one');
|
|
121
148
|
expect(sent?.notebookCells).toEqual(['a = 1']);
|
|
@@ -126,7 +153,7 @@ describe('ChatRouter: the notebook sent with a request', () => {
|
|
|
126
153
|
});
|
|
127
154
|
|
|
128
155
|
it('sends no notebook when there is none', async () => {
|
|
129
|
-
const router = new ChatRouter(settings, async () => null);
|
|
156
|
+
const router = new ChatRouter(settings, noSignIn, async () => null);
|
|
130
157
|
|
|
131
158
|
await router.route('hello');
|
|
132
159
|
|
|
@@ -138,7 +165,7 @@ describe('ChatRouter: the notebook sent with a request', () => {
|
|
|
138
165
|
it('still sends the question when the notebook cannot be read', async () => {
|
|
139
166
|
// Context is best-effort; a notebook that could not be read must not
|
|
140
167
|
// cost the user the answer.
|
|
141
|
-
const router = new ChatRouter(settings, async () => {
|
|
168
|
+
const router = new ChatRouter(settings, noSignIn, async () => {
|
|
142
169
|
throw new Error('could not read');
|
|
143
170
|
});
|
|
144
171
|
|
|
@@ -154,7 +181,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
154
181
|
|
|
155
182
|
it('asks for the stream only when someone is listening', async () => {
|
|
156
183
|
// Without a listener the request is byte-identical to what it always was.
|
|
157
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
184
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
158
185
|
|
|
159
186
|
await router.route('hello');
|
|
160
187
|
expect(sentHeaders?.Accept).toBeUndefined();
|
|
@@ -179,7 +206,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
179
206
|
])
|
|
180
207
|
);
|
|
181
208
|
const progress: unknown[] = [];
|
|
182
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
209
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
183
210
|
|
|
184
211
|
const result = await router.route('find waves', [], undefined, p =>
|
|
185
212
|
progress.push(p)
|
|
@@ -197,7 +224,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
197
224
|
|
|
198
225
|
it('reads JSON when the server answers with JSON anyway', async () => {
|
|
199
226
|
// An older backend, or a proxy that buffered the stream away.
|
|
200
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
227
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
201
228
|
|
|
202
229
|
const result = await router.route('hello', [], undefined, () => undefined);
|
|
203
230
|
|
|
@@ -216,7 +243,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
216
243
|
})
|
|
217
244
|
])
|
|
218
245
|
);
|
|
219
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
246
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
220
247
|
|
|
221
248
|
await expect(
|
|
222
249
|
router.route('hi', [], undefined, () => undefined)
|
|
@@ -230,7 +257,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
230
257
|
it('fails a stream that ends without its answer', async () => {
|
|
231
258
|
// A dropped connection: a failure, not a shorter answer.
|
|
232
259
|
answering(() => streamed([frame('status', { phase: 'generating' })]));
|
|
233
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
260
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
234
261
|
|
|
235
262
|
await expect(
|
|
236
263
|
router.route('hi', [], undefined, () => undefined)
|
|
@@ -261,7 +288,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
261
288
|
})
|
|
262
289
|
}
|
|
263
290
|
}));
|
|
264
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
291
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
265
292
|
|
|
266
293
|
await expect(
|
|
267
294
|
router.route('hi', [], undefined, () => undefined)
|
|
@@ -280,7 +307,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
280
307
|
])
|
|
281
308
|
);
|
|
282
309
|
const progress: unknown[] = [];
|
|
283
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
310
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
284
311
|
|
|
285
312
|
const response = await router.observe(
|
|
286
313
|
'hi',
|
|
@@ -301,7 +328,7 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
301
328
|
streamed([frame('status', { phase: 'generating' })], init.signal, true)
|
|
302
329
|
);
|
|
303
330
|
const progress: unknown[] = [];
|
|
304
|
-
const router = new ChatRouter(settings, noNotebook);
|
|
331
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
305
332
|
|
|
306
333
|
const pending = router.route('hi', [], controller.signal, p =>
|
|
307
334
|
progress.push(p)
|
|
@@ -313,3 +340,218 @@ describe('ChatRouter: the streamed answer', () => {
|
|
|
313
340
|
expect(progress).toEqual([{ phase: 'generating' }]);
|
|
314
341
|
});
|
|
315
342
|
});
|
|
343
|
+
|
|
344
|
+
describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
345
|
+
const noNotebook = async (): Promise<null> => null;
|
|
346
|
+
/** Oceanum Notebook with sign-in configured, and someone signed in. */
|
|
347
|
+
const signedIn = (token: () => unknown) =>
|
|
348
|
+
hostCommands(
|
|
349
|
+
{
|
|
350
|
+
'oceanum-auth:access-token': token,
|
|
351
|
+
'oceanum-auth:sign-in': () => undefined,
|
|
352
|
+
'oceanum-auth:sign-out': () => undefined
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
enabled: ['oceanum-auth:sign-in'],
|
|
356
|
+
visible: ['oceanum-auth:sign-out']
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
it('sends a pasted token as X-Datamesh-Token to the default backend', async () => {
|
|
361
|
+
const router = new ChatRouter(settings, noSignIn, noNotebook);
|
|
362
|
+
|
|
363
|
+
await router.route('hello');
|
|
364
|
+
|
|
365
|
+
expect(sentUrl).toBe('https://ai.oceanum.io/api/chat');
|
|
366
|
+
expect(sentHeaders?.['X-Datamesh-Token']).toBe('a-token');
|
|
367
|
+
expect(sentHeaders?.Authorization).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('prefers a pasted token over the sign-in, without asking for it', async () => {
|
|
371
|
+
// An explicit token is the user's choice, as it is in the kernel.
|
|
372
|
+
const host = signedIn(() => 'a-jwt');
|
|
373
|
+
const router = new ChatRouter(settings, host, noNotebook);
|
|
374
|
+
|
|
375
|
+
await router.route('hello');
|
|
376
|
+
|
|
377
|
+
expect(sentHeaders?.['X-Datamesh-Token']).toBe('a-token');
|
|
378
|
+
expect(sentHeaders?.Authorization).toBeUndefined();
|
|
379
|
+
expect(host.execute).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it.each([
|
|
383
|
+
['no', undefined],
|
|
384
|
+
['an empty', ''],
|
|
385
|
+
['a blank', ' ']
|
|
386
|
+
])(
|
|
387
|
+
'uses the sign-in as a bearer token with %s pasted token',
|
|
388
|
+
async (_, token) => {
|
|
389
|
+
const router = new ChatRouter(
|
|
390
|
+
settingsWith({ datameshToken: token }),
|
|
391
|
+
signedIn(() => 'a-jwt'),
|
|
392
|
+
noNotebook
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
await router.route('hello');
|
|
396
|
+
|
|
397
|
+
expect(sentHeaders?.Authorization).toBe('Bearer a-jwt');
|
|
398
|
+
expect(sentHeaders?.['X-Datamesh-Token']).toBeUndefined();
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
it('asks for the sign-in on every request, so a refreshed token is used', async () => {
|
|
403
|
+
let token = 'first-jwt';
|
|
404
|
+
const router = new ChatRouter(
|
|
405
|
+
settingsWith({}),
|
|
406
|
+
signedIn(() => token),
|
|
407
|
+
noNotebook
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
await router.route('one');
|
|
411
|
+
expect(sentHeaders?.Authorization).toBe('Bearer first-jwt');
|
|
412
|
+
|
|
413
|
+
token = 'second-jwt';
|
|
414
|
+
await router.observe('two', [], []);
|
|
415
|
+
expect(sentHeaders?.Authorization).toBe('Bearer second-jwt');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('sends to the configured address, without a double slash', async () => {
|
|
419
|
+
const router = new ChatRouter(
|
|
420
|
+
settingsWith({
|
|
421
|
+
datameshToken: 'a-token',
|
|
422
|
+
aiBackendUrl: ' https://ai.oceanum.tech/ '
|
|
423
|
+
}),
|
|
424
|
+
noSignIn,
|
|
425
|
+
noNotebook
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
await router.route('hello');
|
|
429
|
+
expect(sentUrl).toBe('https://ai.oceanum.tech/api/chat');
|
|
430
|
+
|
|
431
|
+
await router.observe('hello', [], []);
|
|
432
|
+
expect(sentUrl).toBe('https://ai.oceanum.tech/api/chat/observe');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('sends to the default for an invalid address', async () => {
|
|
436
|
+
const router = new ChatRouter(
|
|
437
|
+
settingsWith({
|
|
438
|
+
datameshToken: 'a-token',
|
|
439
|
+
aiBackendUrl: 'javascript:alert(1)'
|
|
440
|
+
}),
|
|
441
|
+
noSignIn,
|
|
442
|
+
noNotebook
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
await router.route('hello');
|
|
446
|
+
|
|
447
|
+
expect(sentUrl).toBe('https://ai.oceanum.io/api/chat');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('asks the user to sign in when the host can sign in and nobody has', async () => {
|
|
451
|
+
const router = new ChatRouter(
|
|
452
|
+
settingsWith({}),
|
|
453
|
+
signedIn(() => null),
|
|
454
|
+
noNotebook
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
await expect(router.route('hello')).rejects.toMatchObject({
|
|
458
|
+
name: 'ChatRouterError',
|
|
459
|
+
message: expect.stringContaining('Sign in to Oceanum.io')
|
|
460
|
+
});
|
|
461
|
+
expect((globalThis as any).fetch).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('asks for a Datamesh token when the host has no sign-in', async () => {
|
|
465
|
+
const router = new ChatRouter(settingsWith({}), noSignIn, noNotebook);
|
|
466
|
+
|
|
467
|
+
await expect(router.route('hello')).rejects.toThrow(
|
|
468
|
+
'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
|
|
469
|
+
);
|
|
470
|
+
expect((globalThis as any).fetch).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('asks for a Datamesh token where the site has sign-in disabled', async () => {
|
|
474
|
+
// The command is there, but the site has no Oceanum.io environment, so
|
|
475
|
+
// telling the user to sign in would be telling them to do the impossible.
|
|
476
|
+
const router = new ChatRouter(
|
|
477
|
+
settingsWith({}),
|
|
478
|
+
hostCommands({
|
|
479
|
+
'oceanum-auth:access-token': () => null,
|
|
480
|
+
'oceanum-auth:sign-in': () => undefined
|
|
481
|
+
}),
|
|
482
|
+
noNotebook
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
await expect(router.route('hello')).rejects.toThrow(
|
|
486
|
+
'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it.each([
|
|
491
|
+
['rejects', () => Promise.reject(new Error('no'))],
|
|
492
|
+
['answers with something other than a string', () => ({ token: 'x' })]
|
|
493
|
+
])('counts a sign-in command that %s as signed out', async (_, answer) => {
|
|
494
|
+
const router = new ChatRouter(
|
|
495
|
+
settingsWith({}),
|
|
496
|
+
signedIn(answer),
|
|
497
|
+
noNotebook
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
await expect(router.route('hello')).rejects.toThrow(
|
|
501
|
+
'Sign in to Oceanum.io'
|
|
502
|
+
);
|
|
503
|
+
expect((globalThis as any).fetch).not.toHaveBeenCalled();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('says the sign-in was refused on a 401 with the sign-in', async () => {
|
|
507
|
+
answering(() => ({ ok: false, status: 401 }));
|
|
508
|
+
const router = new ChatRouter(
|
|
509
|
+
settingsWith({}),
|
|
510
|
+
signedIn(() => 'a-jwt'),
|
|
511
|
+
noNotebook
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
await expect(router.route('hello')).rejects.toMatchObject({
|
|
515
|
+
message:
|
|
516
|
+
'Your Oceanum.io sign-in was not accepted. Sign in to Oceanum.io again.',
|
|
517
|
+
statusCode: 401
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('says the token was refused on a 401 with a pasted token', async () => {
|
|
522
|
+
answering(() => ({ ok: false, status: 401 }));
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
new ChatRouter(settings, noSignIn, noNotebook).route('hello')
|
|
526
|
+
).rejects.toMatchObject({
|
|
527
|
+
message: 'Invalid or expired Datamesh token.',
|
|
528
|
+
statusCode: 401
|
|
529
|
+
});
|
|
530
|
+
// Where the host can sign in, the pasted token is what stands in the way.
|
|
531
|
+
await expect(
|
|
532
|
+
new ChatRouter(
|
|
533
|
+
settings,
|
|
534
|
+
signedIn(() => 'a-jwt'),
|
|
535
|
+
noNotebook
|
|
536
|
+
).route('hello')
|
|
537
|
+
).rejects.toThrow('clear it to use your Oceanum.io sign-in');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('names the configured address when it cannot be reached', async () => {
|
|
541
|
+
(globalThis as any).fetch = jest.fn(async () => {
|
|
542
|
+
throw new TypeError('Failed to fetch');
|
|
543
|
+
});
|
|
544
|
+
const router = new ChatRouter(
|
|
545
|
+
settingsWith({
|
|
546
|
+
datameshToken: 'a-token',
|
|
547
|
+
aiBackendUrl: 'http://localhost:8000/'
|
|
548
|
+
}),
|
|
549
|
+
noSignIn,
|
|
550
|
+
noNotebook
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
await expect(router.route('hello')).rejects.toThrow(
|
|
554
|
+
'Could not reach Oceanum AI backend at http://localhost:8000. Is it running?'
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import schema from '../../schema/datamesh-connect.json';
|
|
2
|
+
import { DATAMESH_UI_SERVICE } from '../constants';
|
|
3
|
+
import {
|
|
4
|
+
datameshUiSrc,
|
|
5
|
+
isDatameshUiMessage,
|
|
6
|
+
validDatameshUiUrl
|
|
7
|
+
} from '../datameshUiUrl';
|
|
8
|
+
|
|
9
|
+
describe('validDatameshUiUrl', () => {
|
|
10
|
+
it('is the default the settings schema offers', () => {
|
|
11
|
+
expect(schema.properties.datameshUiUrl.default).toBe(DATAMESH_UI_SERVICE);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('takes an http(s) address as it is', () => {
|
|
15
|
+
expect(validDatameshUiUrl('https://ui.datamesh.oceanum.tech').href).toBe(
|
|
16
|
+
'https://ui.datamesh.oceanum.tech/'
|
|
17
|
+
);
|
|
18
|
+
expect(validDatameshUiUrl('http://localhost:3000').href).toBe(
|
|
19
|
+
'http://localhost:3000/'
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
['empty', ''],
|
|
25
|
+
['blank', ' '],
|
|
26
|
+
['missing', undefined],
|
|
27
|
+
['null', null],
|
|
28
|
+
['not a URL', 'not a url'],
|
|
29
|
+
['a relative path', '/datamesh'],
|
|
30
|
+
['a javascript: URL', 'javascript:alert(1)'],
|
|
31
|
+
['a data: URL', 'data:text/html,<script>alert(1)</script>'],
|
|
32
|
+
['a file: URL', 'file:///etc/passwd']
|
|
33
|
+
])('falls back to the default for %s', (_, url) => {
|
|
34
|
+
expect(validDatameshUiUrl(url).href).toBe(`${DATAMESH_UI_SERVICE}/`);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('datameshUiSrc', () => {
|
|
39
|
+
it('opens the default Datamesh UI in embed mode', () => {
|
|
40
|
+
expect(datameshUiSrc(DATAMESH_UI_SERVICE)).toBe(
|
|
41
|
+
'https://ui.datamesh.oceanum.io/?embed=1'
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps the query and fragment a custom address already has', () => {
|
|
46
|
+
const src = new URL(
|
|
47
|
+
datameshUiSrc('https://ui.datamesh.oceanum.tech/app?workspace=abc#top')
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(src.origin).toBe('https://ui.datamesh.oceanum.tech');
|
|
51
|
+
expect(src.pathname).toBe('/app');
|
|
52
|
+
expect(src.searchParams.get('workspace')).toBe('abc');
|
|
53
|
+
expect(src.searchParams.get('embed')).toBe('1');
|
|
54
|
+
expect(src.hash).toBe('#top');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not add embed twice', () => {
|
|
58
|
+
const src = new URL(
|
|
59
|
+
datameshUiSrc('https://ui.datamesh.oceanum.io?embed=0')
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(src.searchParams.getAll('embed')).toEqual(['1']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('opens the default for an invalid address', () => {
|
|
66
|
+
expect(datameshUiSrc('not a url')).toBe(
|
|
67
|
+
'https://ui.datamesh.oceanum.io/?embed=1'
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('never puts a javascript: URL in the iframe', () => {
|
|
72
|
+
expect(datameshUiSrc('javascript:alert(1)')).toBe(
|
|
73
|
+
'https://ui.datamesh.oceanum.io/?embed=1'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('isDatameshUiMessage', () => {
|
|
79
|
+
const frame = {} as Window;
|
|
80
|
+
const other = {} as Window;
|
|
81
|
+
|
|
82
|
+
it('accepts a message from the panel at the configured origin', () => {
|
|
83
|
+
expect(
|
|
84
|
+
isDatameshUiMessage(
|
|
85
|
+
{ origin: 'https://ui.datamesh.oceanum.io', source: frame },
|
|
86
|
+
DATAMESH_UI_SERVICE,
|
|
87
|
+
frame
|
|
88
|
+
)
|
|
89
|
+
).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('compares origins, not whole addresses', () => {
|
|
93
|
+
expect(
|
|
94
|
+
isDatameshUiMessage(
|
|
95
|
+
{ origin: 'https://ui.datamesh.oceanum.tech', source: frame },
|
|
96
|
+
'https://ui.datamesh.oceanum.tech/app?workspace=abc',
|
|
97
|
+
frame
|
|
98
|
+
)
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.each([
|
|
103
|
+
['another site', 'https://evil.example'],
|
|
104
|
+
['the production UI when a dev one is configured', DATAMESH_UI_SERVICE],
|
|
105
|
+
['another port', 'https://ui.datamesh.oceanum.tech:8443'],
|
|
106
|
+
['plain http', 'http://ui.datamesh.oceanum.tech'],
|
|
107
|
+
['an opaque origin', 'null']
|
|
108
|
+
])('rejects a message from %s', (_, origin) => {
|
|
109
|
+
expect(
|
|
110
|
+
isDatameshUiMessage(
|
|
111
|
+
{ origin, source: frame },
|
|
112
|
+
'https://ui.datamesh.oceanum.tech',
|
|
113
|
+
frame
|
|
114
|
+
)
|
|
115
|
+
).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('checks the origin against the default for an invalid setting', () => {
|
|
119
|
+
expect(
|
|
120
|
+
isDatameshUiMessage(
|
|
121
|
+
{ origin: 'https://ui.datamesh.oceanum.io', source: frame },
|
|
122
|
+
'javascript:alert(1)',
|
|
123
|
+
frame
|
|
124
|
+
)
|
|
125
|
+
).toBe(true);
|
|
126
|
+
expect(
|
|
127
|
+
isDatameshUiMessage(
|
|
128
|
+
{ origin: 'null', source: frame },
|
|
129
|
+
'javascript:alert(1)',
|
|
130
|
+
frame
|
|
131
|
+
)
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects the right origin from another window', () => {
|
|
136
|
+
expect(
|
|
137
|
+
isDatameshUiMessage(
|
|
138
|
+
{ origin: 'https://ui.datamesh.oceanum.io', source: other },
|
|
139
|
+
DATAMESH_UI_SERVICE,
|
|
140
|
+
frame
|
|
141
|
+
)
|
|
142
|
+
).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('rejects every message while the panel is closed', () => {
|
|
146
|
+
expect(
|
|
147
|
+
isDatameshUiMessage(
|
|
148
|
+
{ origin: 'https://ui.datamesh.oceanum.io', source: null },
|
|
149
|
+
DATAMESH_UI_SERVICE,
|
|
150
|
+
null
|
|
151
|
+
)
|
|
152
|
+
).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/aiBackend.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Oceanum AI backend is, and what a request to it is signed with.
|
|
3
|
+
*
|
|
4
|
+
* The backend accepts either a Datamesh token (`X-Datamesh-Token`) or an
|
|
5
|
+
* Oceanum.io access token (`Authorization: Bearer`). A token pasted into the
|
|
6
|
+
* settings is explicit, so it wins; otherwise a host that signs users in to
|
|
7
|
+
* Oceanum.io -- Oceanum Notebook -- lends its sign-in through its commands.
|
|
8
|
+
* Plain JupyterLab has no such commands and needs the pasted token.
|
|
9
|
+
*
|
|
10
|
+
* Whether the user is signed in, or can sign in, is read with the registry's
|
|
11
|
+
* queries (`isVisible`, `isEnabled`), never by running a command: every
|
|
12
|
+
* `execute` emits `commandExecuted`, which closes JupyterLab's command palette.
|
|
13
|
+
* The access-token command is run only when a token is actually needed.
|
|
14
|
+
*/
|
|
15
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
16
|
+
import { OCEANUM_AI_BACKEND_URL } from './constants';
|
|
17
|
+
import { validHttpUrl } from './httpUrl';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The host's command answering with the current Oceanum.io access token (a
|
|
21
|
+
* JWT), or null when signed out. The token is cached and refreshed by the
|
|
22
|
+
* host, so it is asked for on every use rather than kept.
|
|
23
|
+
*/
|
|
24
|
+
export const ACCESS_TOKEN_COMMAND = 'oceanum-auth:access-token';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The host's command that starts signing in to Oceanum.io. Enabled only where
|
|
28
|
+
* sign-in is configured for the site.
|
|
29
|
+
*/
|
|
30
|
+
export const SIGN_IN_COMMAND = 'oceanum-auth:sign-in';
|
|
31
|
+
|
|
32
|
+
/** The host's command that signs out. Visible only while signed in. */
|
|
33
|
+
export const SIGN_OUT_COMMAND = 'oceanum-auth:sign-out';
|
|
34
|
+
|
|
35
|
+
/** The part of the command registry the sign-in is reached through. */
|
|
36
|
+
export type AuthCommands = Pick<
|
|
37
|
+
CommandRegistry,
|
|
38
|
+
'hasCommand' | 'execute' | 'isEnabled' | 'isVisible'
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
export type AiCredentialSource = 'datamesh-token' | 'sign-in';
|
|
42
|
+
|
|
43
|
+
export interface IAiCredential {
|
|
44
|
+
/** Which credential it is: the 401 message differs. */
|
|
45
|
+
source: AiCredentialSource;
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The backend address for an `aiBackendUrl` setting value, without a trailing
|
|
51
|
+
* slash so a path can be appended. An invalid value falls back to the default.
|
|
52
|
+
*/
|
|
53
|
+
export function aiBackendUrl(value: string | null | undefined): string {
|
|
54
|
+
const url = validHttpUrl(value, OCEANUM_AI_BACKEND_URL);
|
|
55
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A `datameshToken` setting value, trimmed; '' when there is none. */
|
|
59
|
+
export function pastedToken(datameshToken: unknown): string {
|
|
60
|
+
return typeof datameshToken === 'string' ? datameshToken.trim() : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Asks a query of the host's command, treating a missing command, or a query
|
|
65
|
+
* that throws, as false. Queries never emit `commandExecuted`.
|
|
66
|
+
*/
|
|
67
|
+
function query(
|
|
68
|
+
commands: AuthCommands,
|
|
69
|
+
id: string,
|
|
70
|
+
which: 'isEnabled' | 'isVisible'
|
|
71
|
+
): boolean {
|
|
72
|
+
try {
|
|
73
|
+
return commands.hasCommand(id) && commands[which](id);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
80
|
+
export function canSignIn(commands: AuthCommands): boolean {
|
|
81
|
+
return query(commands, SIGN_IN_COMMAND, 'isEnabled');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the host says the user is signed in to Oceanum.io. */
|
|
85
|
+
export function isSignedIn(commands: AuthCommands): boolean {
|
|
86
|
+
return query(commands, SIGN_OUT_COMMAND, 'isVisible');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Which credential a request would carry, without fetching it: runs no
|
|
91
|
+
* command, so it is safe to ask whenever something changes.
|
|
92
|
+
*/
|
|
93
|
+
export function aiCredentialSource(
|
|
94
|
+
datameshToken: unknown,
|
|
95
|
+
commands: AuthCommands
|
|
96
|
+
): AiCredentialSource | null {
|
|
97
|
+
if (pastedToken(datameshToken)) {
|
|
98
|
+
return 'datamesh-token';
|
|
99
|
+
}
|
|
100
|
+
if (commands.hasCommand(ACCESS_TOKEN_COMMAND) && isSignedIn(commands)) {
|
|
101
|
+
return 'sign-in';
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Asks the host for its Oceanum.io access token; null without the command.
|
|
108
|
+
* This runs a command: call it only when a token is needed for a request.
|
|
109
|
+
*/
|
|
110
|
+
export function signInToken(commands: AuthCommands): () => Promise<unknown> {
|
|
111
|
+
return async () =>
|
|
112
|
+
commands.hasCommand(ACCESS_TOKEN_COMMAND)
|
|
113
|
+
? commands.execute(ACCESS_TOKEN_COMMAND)
|
|
114
|
+
: null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The credential for a request: the pasted Datamesh token if there is one,
|
|
119
|
+
* otherwise the host's sign-in, otherwise null.
|
|
120
|
+
*
|
|
121
|
+
* @param datameshToken The `datameshToken` setting value.
|
|
122
|
+
* @param getSignInToken Asked only without a pasted token. Anything but a
|
|
123
|
+
* non-empty string -- including a rejection -- counts as signed out.
|
|
124
|
+
*/
|
|
125
|
+
export async function resolveAiCredential(
|
|
126
|
+
datameshToken: unknown,
|
|
127
|
+
getSignInToken: () => Promise<unknown>
|
|
128
|
+
): Promise<IAiCredential | null> {
|
|
129
|
+
const pasted = pastedToken(datameshToken);
|
|
130
|
+
if (pasted) {
|
|
131
|
+
return {
|
|
132
|
+
source: 'datamesh-token',
|
|
133
|
+
headers: { 'X-Datamesh-Token': pasted }
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
let token: unknown;
|
|
137
|
+
try {
|
|
138
|
+
token = await getSignInToken();
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const signedIn = typeof token === 'string' ? token.trim() : '';
|
|
143
|
+
if (!signedIn) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
source: 'sign-in',
|
|
148
|
+
headers: { Authorization: `Bearer ${signedIn}` }
|
|
149
|
+
};
|
|
150
|
+
}
|