@oceanum/oceanumlab 4.6.2 → 4.7.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.
Files changed (62) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +24 -2
  5. package/lib/DatameshWidget.js +171 -45
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +66 -0
  11. package/lib/aiBackend.js +101 -0
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +17 -1
  23. package/lib/chatRouter.js +34 -9
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +8 -16
  28. package/lib/httpUrl.d.ts +9 -0
  29. package/lib/httpUrl.js +20 -0
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +67 -15
  32. package/lib/specStore.d.ts +37 -0
  33. package/lib/specStore.js +57 -0
  34. package/package.json +9 -1
  35. package/schema/datamesh-connect.json +3 -6
  36. package/src/DatameshUI.tsx +1 -10
  37. package/src/DatameshWidget.tsx +294 -59
  38. package/src/StoredNotebooks.tsx +166 -0
  39. package/src/Tabs.tsx +102 -0
  40. package/src/__tests__/DatameshWidget.spec.tsx +393 -0
  41. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  42. package/src/__tests__/Tabs.spec.tsx +168 -0
  43. package/src/__tests__/aiBackend.spec.ts +271 -0
  44. package/src/__tests__/chatRouter.spec.ts +253 -17
  45. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  46. package/src/__tests__/pluginArity.spec.ts +24 -0
  47. package/src/__tests__/serverAuth.spec.ts +277 -0
  48. package/src/__tests__/settingsSchema.spec.ts +73 -0
  49. package/src/__tests__/specStore.spec.ts +126 -0
  50. package/src/aiBackend.ts +150 -0
  51. package/src/auth/accountWidget.ts +113 -0
  52. package/src/auth/claims.ts +30 -0
  53. package/src/auth/plugin.ts +155 -0
  54. package/src/auth/serverAuth.ts +341 -0
  55. package/src/auth/tokens.ts +104 -0
  56. package/src/chatRouter.ts +41 -9
  57. package/src/constants.ts +1 -1
  58. package/src/datameshUiUrl.ts +8 -15
  59. package/src/httpUrl.ts +23 -0
  60. package/src/index.ts +99 -33
  61. package/src/specStore.ts +82 -0
  62. package/style/index.css +158 -0
@@ -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
- const settings = {
5
- get: () => ({ composite: 'a-token' })
6
- } as unknown as ConstructorParameters<typeof ChatRouter>[0];
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 (_url: string, init: IRequestInit) => {
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,212 @@ 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
+ settings,
421
+ noSignIn,
422
+ noNotebook,
423
+ () => ' https://ai.oceanum.tech/ '
424
+ );
425
+
426
+ await router.route('hello');
427
+ expect(sentUrl).toBe('https://ai.oceanum.tech/api/chat');
428
+
429
+ await router.observe('hello', [], []);
430
+ expect(sentUrl).toBe('https://ai.oceanum.tech/api/chat/observe');
431
+ });
432
+
433
+ it('sends to the default for an invalid address', async () => {
434
+ const router = new ChatRouter(
435
+ settings,
436
+ noSignIn,
437
+ noNotebook,
438
+ () => 'javascript:alert(1)'
439
+ );
440
+
441
+ await router.route('hello');
442
+
443
+ expect(sentUrl).toBe('https://ai.oceanum.io/api/chat');
444
+ });
445
+
446
+ it('asks the user to sign in when the host can sign in and nobody has', async () => {
447
+ const router = new ChatRouter(
448
+ settingsWith({}),
449
+ signedIn(() => null),
450
+ noNotebook
451
+ );
452
+
453
+ await expect(router.route('hello')).rejects.toMatchObject({
454
+ name: 'ChatRouterError',
455
+ message: expect.stringContaining('Sign in to Oceanum.io')
456
+ });
457
+ expect((globalThis as any).fetch).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it('asks for a Datamesh token when the host has no sign-in', async () => {
461
+ const router = new ChatRouter(settingsWith({}), noSignIn, noNotebook);
462
+
463
+ await expect(router.route('hello')).rejects.toThrow(
464
+ 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
465
+ );
466
+ expect((globalThis as any).fetch).not.toHaveBeenCalled();
467
+ });
468
+
469
+ it('asks for a Datamesh token where the site has sign-in disabled', async () => {
470
+ // The command is there, but the site has no Oceanum.io environment, so
471
+ // telling the user to sign in would be telling them to do the impossible.
472
+ const router = new ChatRouter(
473
+ settingsWith({}),
474
+ hostCommands({
475
+ 'oceanum-auth:access-token': () => null,
476
+ 'oceanum-auth:sign-in': () => undefined
477
+ }),
478
+ noNotebook
479
+ );
480
+
481
+ await expect(router.route('hello')).rejects.toThrow(
482
+ 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
483
+ );
484
+ });
485
+
486
+ it.each([
487
+ ['rejects', () => Promise.reject(new Error('no'))],
488
+ ['answers with something other than a string', () => ({ token: 'x' })]
489
+ ])('counts a sign-in command that %s as signed out', async (_, answer) => {
490
+ const router = new ChatRouter(
491
+ settingsWith({}),
492
+ signedIn(answer),
493
+ noNotebook
494
+ );
495
+
496
+ await expect(router.route('hello')).rejects.toThrow(
497
+ 'Sign in to Oceanum.io'
498
+ );
499
+ expect((globalThis as any).fetch).not.toHaveBeenCalled();
500
+ });
501
+
502
+ it('says the sign-in was refused on a 401 with the sign-in', async () => {
503
+ answering(() => ({ ok: false, status: 401 }));
504
+ const router = new ChatRouter(
505
+ settingsWith({}),
506
+ signedIn(() => 'a-jwt'),
507
+ noNotebook
508
+ );
509
+
510
+ await expect(router.route('hello')).rejects.toMatchObject({
511
+ message:
512
+ 'Your Oceanum.io sign-in was not accepted. Sign in to Oceanum.io again.',
513
+ statusCode: 401
514
+ });
515
+ });
516
+
517
+ it('says the token was refused on a 401 with a pasted token', async () => {
518
+ answering(() => ({ ok: false, status: 401 }));
519
+
520
+ await expect(
521
+ new ChatRouter(settings, noSignIn, noNotebook).route('hello')
522
+ ).rejects.toMatchObject({
523
+ message: 'Invalid or expired Datamesh token.',
524
+ statusCode: 401
525
+ });
526
+ // Where the host can sign in, the pasted token is what stands in the way.
527
+ await expect(
528
+ new ChatRouter(
529
+ settings,
530
+ signedIn(() => 'a-jwt'),
531
+ noNotebook
532
+ ).route('hello')
533
+ ).rejects.toThrow('clear it to use your Oceanum.io sign-in');
534
+ });
535
+
536
+ it('names the configured address when it cannot be reached', async () => {
537
+ (globalThis as any).fetch = jest.fn(async () => {
538
+ throw new TypeError('Failed to fetch');
539
+ });
540
+ const router = new ChatRouter(
541
+ settings,
542
+ noSignIn,
543
+ noNotebook,
544
+ () => 'http://localhost:8000/'
545
+ );
546
+
547
+ await expect(router.route('hello')).rejects.toThrow(
548
+ 'Could not reach Oceanum AI backend at http://localhost:8000. Is it running?'
549
+ );
550
+ });
551
+ });
@@ -1,4 +1,3 @@
1
- import schema from '../../schema/datamesh-connect.json';
2
1
  import { DATAMESH_UI_SERVICE } from '../constants';
3
2
  import {
4
3
  datameshUiSrc,
@@ -7,8 +6,10 @@ import {
7
6
  } from '../datameshUiUrl';
8
7
 
9
8
  describe('validDatameshUiUrl', () => {
10
- it('is the default the settings schema offers', () => {
11
- expect(schema.properties.datameshUiUrl.default).toBe(DATAMESH_UI_SERVICE);
9
+ it('is production when the deployment names no address', () => {
10
+ expect(validDatameshUiUrl(undefined).href).toBe(
11
+ new URL(DATAMESH_UI_SERVICE).href
12
+ );
12
13
  });
13
14
 
14
15
  it('takes an http(s) address as it is', () => {
@@ -0,0 +1,24 @@
1
+ import plugins from '../index';
2
+
3
+ /**
4
+ * JupyterLab passes `requires` then `optional` to `activate`, positionally, after the
5
+ * application. Omitting a parameter for a dependency you do not use does not skip it —
6
+ * it shifts every later argument along by one, silently handing the wrong object to the
7
+ * wrong parameter. TypeScript cannot see this: `activate` is typed loosely enough that
8
+ * the mismatch compiles, and unit tests that never activate the plugin never notice.
9
+ *
10
+ * This bit exactly once: adding an optional IOceanumAuth to a plugin whose signature
11
+ * had quietly omitted its IStateDB meant `auth` received the state database, and the
12
+ * panel crashed on `auth.userChanged.connect` at application start.
13
+ */
14
+ describe('plugin activate signatures', () => {
15
+ it.each(plugins.map(plugin => [plugin.id, plugin] as const))(
16
+ '%s takes one parameter per declared dependency',
17
+ (_id, plugin) => {
18
+ const declared =
19
+ (plugin.requires?.length ?? 0) + (plugin.optional?.length ?? 0);
20
+ // +1 for the JupyterFrontEnd itself.
21
+ expect(plugin.activate.length).toBe(declared + 1);
22
+ }
23
+ );
24
+ });