@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
@@ -0,0 +1,104 @@
1
+ import { Token } from '@lumino/coreutils';
2
+ import { ISignal } from '@lumino/signaling';
3
+
4
+ /**
5
+ * One Oceanum deployment (production or development).
6
+ */
7
+ export interface IOceanumEnvironment {
8
+ /** Page hostnames served by this environment, e.g. `notebook.oceanum.io`. */
9
+ readonly hosts: readonly string[];
10
+ /** Auth0 tenant domain, e.g. `auth.oceanum.io`. */
11
+ readonly auth0Domain: string;
12
+ /** Auth0 SPA client id. */
13
+ readonly clientId: string;
14
+ /**
15
+ * The Oceanum apps domain, e.g. `oceanum.io`. The Oceanum nav builds its app, account,
16
+ * docs and sign-out URLs from it (`https://platform.oceanum.io/account`, ...).
17
+ */
18
+ readonly oceanumDomain: string;
19
+ /** Service base URLs for this environment. */
20
+ readonly urls: IOceanumServiceUrls;
21
+ /**
22
+ * Whether to check for an existing Oceanum.io session with a full-page redirect when the
23
+ * silent iframe check finds none. Only for a tenant on another site than the notebook
24
+ * (browsers that block third-party cookies hide the session from the iframe), and only where
25
+ * the site origin is an allowed callback URL.
26
+ */
27
+ readonly signInRedirect: boolean;
28
+ }
29
+
30
+ /** Service base URLs for an environment, without trailing slashes. */
31
+ export interface IOceanumServiceUrls {
32
+ /** Datamesh gateway, e.g. `https://datamesh.oceanum.io`. */
33
+ readonly datamesh: string;
34
+ /** Spec store, e.g. `https://specs.oceanum.io`. */
35
+ readonly specs: string;
36
+ /** User management service (Auth0 user metadata), e.g. `https://manage.oceanum.io`. */
37
+ readonly manage: string;
38
+ /**
39
+ * Datamesh UI, e.g. `https://ui.datamesh.oceanum.io`, shown by oceanumlab's Datamesh panel.
40
+ * Optional: without it, Oceanum production applies.
41
+ */
42
+ readonly datameshUi?: string;
43
+ /**
44
+ * Oceanum AI backend, e.g. `https://ai.oceanum.io`, called by oceanumlab's AI chat.
45
+ * Optional: without it, Oceanum production applies.
46
+ */
47
+ readonly ai?: string;
48
+ }
49
+
50
+ export type ColorScheme = 'light' | 'dark' | 'auto';
51
+
52
+ /** The signed-in user, from the Auth0 ID token claims. */
53
+ export interface IOceanumUser {
54
+ readonly sub: string;
55
+ readonly email: string;
56
+ readonly name: string | null;
57
+ /** `https://oceanum.io/active_org`. */
58
+ readonly activeOrg: string | null;
59
+ /** `https://oceanum.io/color_scheme`. */
60
+ readonly colorScheme: ColorScheme | null;
61
+ }
62
+
63
+ /**
64
+ * Oceanum.io sign-in state, shared with other Oceanum Notebook extensions.
65
+ */
66
+ export interface IOceanumAuth {
67
+ /** The environment for this page, or `null` if sign-in is not configured for this host. */
68
+ readonly environment: IOceanumEnvironment | null;
69
+ /** Service URLs for `environment`, or `null` when it is `null`. */
70
+ readonly urls: IOceanumServiceUrls | null;
71
+ /** Resolves once any login redirect has been handled and the session restored. */
72
+ readonly ready: Promise<void>;
73
+ /** The signed-in user, or `null`. */
74
+ readonly user: IOceanumUser | null;
75
+ /** Emits the new user (or `null`) on sign-in and sign-out. */
76
+ readonly userChanged: ISignal<IOceanumAuth, IOceanumUser | null>;
77
+ /** Emits the new access token (or `null`) whenever it changes, including refreshes. */
78
+ readonly tokenChanged: ISignal<IOceanumAuth, string | null>;
79
+ /**
80
+ * Start sign-in without leaving the page, so its kernels survive. Resolves once sign-in has
81
+ * started, not when it completes; watch `userChanged` for the result. Does nothing while a
82
+ * user is signed in.
83
+ */
84
+ signIn(): Promise<void>;
85
+ /** Sign out of Oceanum.io and clear the session. */
86
+ signOut(): Promise<void>;
87
+ /**
88
+ * A current access token, refreshed if it is near expiry, or `null` when signed out.
89
+ * Never cache the result: Auth0 access tokens are short-lived.
90
+ */
91
+ getAccessToken(): Promise<string | null>;
92
+ }
93
+
94
+ /**
95
+ * The sign-in contract. oceanumlab owns it and exports it from its package entry, because a
96
+ * Lumino token is matched by object identity: every extension that provides or consumes it
97
+ * has to import this one object, which JupyterLab arranges by sharing `@oceanum/oceanumlab`
98
+ * as a singleton module. On an ordinary JupyterLab oceanumlab's own device sign-in provides
99
+ * it; on notebook.oceanum.io the notebook's Oceanum widget extension does.
100
+ */
101
+ export const IOceanumAuth = new Token<IOceanumAuth>(
102
+ '@oceanum/oceanumlab:IOceanumAuth',
103
+ 'Oceanum.io sign-in state and Datamesh access tokens.'
104
+ );
package/src/chatRouter.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
- import { OCEANUM_AI_BACKEND_URL } from './constants';
2
+ import {
3
+ aiBackendUrl,
4
+ canSignIn,
5
+ resolveAiCredential,
6
+ signInToken,
7
+ type AuthCommands
8
+ } from './aiBackend';
3
9
  import type { Progress } from './progress';
4
10
  import { readSse } from './sse';
5
11
  import type { ObservedRun } from './notebookRun';
@@ -76,6 +82,8 @@ function asOceanumResponse(data: unknown): OceanumResponse {
76
82
 
77
83
  export class ChatRouter {
78
84
  /**
85
+ * @param _commands Where the host's Oceanum.io sign-in is asked for, when
86
+ * there is no pasted Datamesh token.
79
87
  * @param _notebook What the conversation's notebook holds -- the one its
80
88
  * answers are placed in -- or null if there is nothing to send. Asked on
81
89
  * every request, so it follows the conversation rather than whichever tab
@@ -83,7 +91,14 @@ export class ChatRouter {
83
91
  */
84
92
  constructor(
85
93
  private _settings: ISettingRegistry.ISettings,
86
- private _notebook: () => Promise<INotebookSnapshot | null>
94
+ private _commands: AuthCommands,
95
+ private _notebook: () => Promise<INotebookSnapshot | null>,
96
+ /**
97
+ * The Oceanum AI backend's address: the signed-in deployment's, or empty for the
98
+ * production default. Deployment configuration, not a setting -- a user-editable
99
+ * address is somewhere to send the user's credential.
100
+ */
101
+ private _backend: () => string = () => ''
87
102
  ) {}
88
103
 
89
104
  async route(
@@ -178,20 +193,28 @@ export class ChatRouter {
178
193
  signal?: AbortSignal,
179
194
  onProgress?: (progress: Progress) => void
180
195
  ): Promise<unknown> {
181
- const token = this._settings.get('datameshToken').composite as string;
182
- if (!token) {
196
+ // Resolved per request, not once: the sign-in token refreshes, and the
197
+ // user can sign in, sign out or paste a token between two requests.
198
+ const credential = await resolveAiCredential(
199
+ this._settings.get('datameshToken').composite,
200
+ signInToken(this._commands)
201
+ );
202
+ if (!credential) {
183
203
  throw new ChatRouterError(
184
- 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
204
+ canSignIn(this._commands)
205
+ ? 'Not signed in. Sign in to Oceanum.io to use Oceanum AI, or set a Datamesh token in Settings → Oceanum.io.'
206
+ : 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
185
207
  );
186
208
  }
209
+ const backend = aiBackendUrl(this._backend());
187
210
 
188
211
  let response: Response;
189
212
  try {
190
- response = await fetch(`${OCEANUM_AI_BACKEND_URL}${path}`, {
213
+ response = await fetch(`${backend}${path}`, {
191
214
  method: 'POST',
192
215
  headers: {
193
216
  'Content-Type': 'application/json',
194
- 'X-Datamesh-Token': token,
217
+ ...credential.headers,
195
218
  // Ask for the streamed form only when someone is listening. The
196
219
  // server requires the media type to be NAMED -- a wildcard never
197
220
  // selects it -- so omitting this is what keeps the plain JSON path
@@ -208,12 +231,21 @@ export class ChatRouter {
208
231
  throw err;
209
232
  }
210
233
  throw new ChatRouterError(
211
- `Could not reach Oceanum AI backend at ${OCEANUM_AI_BACKEND_URL}. Is it running?`
234
+ `Could not reach Oceanum AI backend at ${backend}. Is it running?`
212
235
  );
213
236
  }
214
237
 
215
238
  if (response.status === 401) {
216
- throw new ChatRouterError('Invalid or expired Datamesh token.', 401);
239
+ let message = 'Invalid or expired Datamesh token.';
240
+ if (credential.source === 'sign-in') {
241
+ message =
242
+ 'Your Oceanum.io sign-in was not accepted. Sign in to Oceanum.io again.';
243
+ } else if (canSignIn(this._commands)) {
244
+ // A pasted token wins over the sign-in, so say how to fall back to it.
245
+ message +=
246
+ ' Update it, or clear it to use your Oceanum.io sign-in, in Settings → Oceanum.io.';
247
+ }
248
+ throw new ChatRouterError(message, 401);
217
249
  }
218
250
 
219
251
  if (!response.ok) {
package/src/constants.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * These values are compiled into the extension.
4
4
  */
5
5
 
6
- /** URL of the Oceanum AI backend service */
6
+ /** Default URL of the Oceanum AI backend service, where the deployment names none. */
7
7
  export const OCEANUM_AI_BACKEND_URL = 'https://ai.oceanum.io';
8
8
 
9
9
  /** URL of the Datamesh UI embedded in the Datamesh panel */
@@ -1,29 +1,22 @@
1
1
  /**
2
2
  * The Datamesh UI panel's address, and which messages may come from it.
3
3
  *
4
- * The address is a setting (`datameshUiUrl`), so a deployment can point the
5
- * panel at another Datamesh UI -- a dev one, say. A setting is typed in by
6
- * hand, so anything that is not an http(s) URL falls back to the default
7
- * rather than ending up in an iframe's `src`.
4
+ * The address comes from the deployment's sign-in environment, so a development
5
+ * deployment can point the panel at its own Datamesh UI. It is still written by a
6
+ * person in a config file, so anything that is not an http(s) URL falls back to the
7
+ * default rather than ending up in an iframe's `src`.
8
8
  */
9
9
  import { DATAMESH_UI_SERVICE } from './constants';
10
+ import { validHttpUrl } from './httpUrl';
10
11
 
11
- /** The Datamesh UI address to use for a `datameshUiUrl` setting value. */
12
+ /** The Datamesh UI address to use for a configured value. */
12
13
  export function validDatameshUiUrl(url: string | null | undefined): URL {
13
- try {
14
- const parsed = new URL((url ?? '').trim());
15
- if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
16
- return parsed;
17
- }
18
- } catch {
19
- // Empty or not a URL: the default below.
20
- }
21
- return new URL(DATAMESH_UI_SERVICE);
14
+ return validHttpUrl(url, DATAMESH_UI_SERVICE);
22
15
  }
23
16
 
24
17
  /**
25
18
  * The panel's iframe `src`: the Datamesh UI in embed mode, which hides the
26
- * Oceanum.io navigator. Any query the setting already has is kept.
19
+ * Oceanum.io navigator. Any query the address already has is kept.
27
20
  */
28
21
  export function datameshUiSrc(url: string | null | undefined): string {
29
22
  const src = validDatameshUiUrl(url);
package/src/httpUrl.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * An address typed into a setting, checked before anything uses it.
3
+ *
4
+ * A setting is typed in by hand, so anything that is not an http(s) URL falls
5
+ * back to the default rather than ending up in an iframe's `src` or a
6
+ * request's address.
7
+ */
8
+
9
+ /** `value` as an http(s) URL, or `fallback` when it is empty or not one. */
10
+ export function validHttpUrl(
11
+ value: string | null | undefined,
12
+ fallback: string
13
+ ): URL {
14
+ try {
15
+ const parsed = new URL((value ?? '').trim());
16
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
17
+ return parsed;
18
+ }
19
+ } catch {
20
+ // Empty or not a URL: the default below.
21
+ }
22
+ return new URL(fallback);
23
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { ICommandPalette, Notification } from '@jupyterlab/apputils';
8
8
  import { LabIcon } from '@jupyterlab/ui-components';
9
9
  import { find } from '@lumino/algorithm';
10
+ import { Signal } from '@lumino/signaling';
10
11
  import { Widget } from '@lumino/widgets';
11
12
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
12
13
  import { IStateDB } from '@jupyterlab/statedb';
@@ -17,11 +18,18 @@ import { requestAPI } from './handler';
17
18
  import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
18
19
  import { reporterFor } from './progress';
19
20
  import { ConversationPin } from './conversationPin';
21
+ import { deviceAuthPlugin } from './auth/plugin';
22
+ import { IOceanumAuth } from './auth/tokens';
23
+
20
24
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
21
25
  import { notebookHost } from './notebookHost';
22
26
  import { KernelHandoff } from './kernelHandoff';
23
27
  import { runChatLoop, STOPPED } from './aiLoop';
24
- import { DATAMESH_UI_SERVICE, MAX_OBSERVE_ROUNDS } from './constants';
28
+ import {
29
+ DATAMESH_UI_SERVICE,
30
+ MAX_OBSERVE_ROUNDS,
31
+ OCEANUM_AI_BACKEND_URL
32
+ } from './constants';
25
33
 
26
34
  import '../style/index.css';
27
35
 
@@ -48,17 +56,30 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
48
56
  ISettingRegistry,
49
57
  IStateDB
50
58
  ],
59
+ // Optional so the panel still loads on a host with no Oceanum environment; the
60
+ // Notebooks tab then explains why it is empty instead of the panel failing to start.
61
+ optional: [IOceanumAuth],
51
62
  activate: (
52
63
  app: JupyterFrontEnd,
53
64
  palette: ICommandPalette,
54
65
  restorer: ILayoutRestorer,
55
66
  status: ILabStatus,
56
- settingRegistry: ISettingRegistry
67
+ settingRegistry: ISettingRegistry,
68
+ stateDB: IStateDB,
69
+ auth: IOceanumAuth | null
57
70
  ) => {
58
71
  console.log('Oceanum datamesh connect extension is loaded');
59
72
 
60
- // The `datameshUiUrl` setting; the default until the settings load.
61
- let datameshUiUrl = DATAMESH_UI_SERVICE;
73
+ // Where the Datamesh UI and Oceanum AI live is the deployment's business, so it comes
74
+ // from the sign-in environment (the Jupyter server's configuration, or the notebook
75
+ // site's) and falls back to production. These were user settings until 4.7.1; a
76
+ // user-editable service address is somewhere to send the user's credential.
77
+ const datameshUiUrl = auth?.urls?.datameshUi ?? DATAMESH_UI_SERVICE;
78
+ const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
79
+ // The `datameshToken` setting for the AI chat; empty until the settings load.
80
+ let datameshTokenSetting = '';
81
+ // Tells the sidebar the settings above have changed.
82
+ const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
62
83
 
63
84
  const findDatameshUI = (): DatameshUI | undefined =>
64
85
  find(
@@ -77,12 +98,8 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
77
98
  }).then(res => console.log(res));
78
99
  }
79
100
  window.injectToken = set.get('injectToken').user as boolean;
80
- // An open Datamesh UI panel follows a change of address.
81
- datameshUiUrl = set.get('datameshUiUrl').composite as string;
82
- const datameshUI = findDatameshUI();
83
- if (datameshUI) {
84
- datameshUI.url = datameshUiUrl;
85
- }
101
+ datameshTokenSetting = set.get('datameshToken').composite as string;
102
+ settingsChanged.emit();
86
103
  };
87
104
  //Try to get the datamesh token from the envars
88
105
 
@@ -127,8 +144,17 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
127
144
  openDatameshUI: openDatameshUI,
128
145
  datameshUiUrl: () => datameshUiUrl,
129
146
  datameshUiFrame: () => findDatameshUI()?.frame ?? null,
147
+ datameshToken: () => datameshTokenSetting,
148
+ aiBackendUrl: () => aiBackendUrl,
149
+ settingsChanged,
130
150
  commands: app.commands,
131
- getCurrentWidget
151
+ getCurrentWidget,
152
+ auth
153
+ // No `openStoredNotebook`: nothing can open a spec store record by id yet.
154
+ // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
155
+ // its own picker — so there is nothing to hand an id to. Opening arrives with
156
+ // the drive in OCE-182, where the contents API makes it the ordinary path.
157
+ // Until then the list reads rather than offering a click that does nothing.
132
158
  });
133
159
  datameshConnectWidget.id = 'datamesh-connect';
134
160
  datameshConnectWidget.title.icon = oceanumIcon;
@@ -136,8 +162,30 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
136
162
 
137
163
  restorer.add(datameshConnectWidget, 'datamesh-connect');
138
164
 
165
+ // The layout restorer restores the widget's place in the shell, not fields on it,
166
+ // so the chosen tab is kept here. Restoring is best effort: a missing or unreadable
167
+ // entry just leaves the default.
168
+ const TAB_STATE_KEY = `${PLUGIN_ID}:tab`;
169
+ void stateDB
170
+ .fetch(TAB_STATE_KEY)
171
+ .then(value => {
172
+ if (typeof value === 'string') {
173
+ datameshConnectWidget.selectTab(value);
174
+ }
175
+ })
176
+ .catch((): void => undefined);
177
+ datameshConnectWidget.tabChanged.connect((_, id) => {
178
+ void stateDB.save(TAB_STATE_KEY, id);
179
+ });
180
+
139
181
  // Rank has been chosen somewhat arbitrarily to give priority to the running
140
182
  // sessions widget in the sidebar.
183
+ //
184
+ // Oceanum Notebook wants this panel first instead, but that is a property of that
185
+ // distribution rather than of the extension: in a plain JupyterLab the file browser
186
+ // is the primary navigation surface and displacing it would be a regression. A
187
+ // distribution moves it with JupyterLab's own shell user-layout settings, keyed on
188
+ // this widget's id, rather than this extension hard-coding one host's preference.
141
189
  app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
142
190
 
143
191
  app.commands.addCommand('datamesh-ui:open', {
@@ -157,10 +205,12 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
157
205
  id: '@oceanum/oceanumlab:ai-chat',
158
206
  autoStart: true,
159
207
  requires: [INotebookTracker, ISettingRegistry],
208
+ optional: [IOceanumAuth],
160
209
  activate: (
161
210
  app: JupyterFrontEnd,
162
211
  notebookTracker: INotebookTracker,
163
- settingRegistry: ISettingRegistry
212
+ settingRegistry: ISettingRegistry,
213
+ auth: IOceanumAuth | null
164
214
  ) => {
165
215
  console.log('Oceanum AI chat extension is loaded');
166
216
 
@@ -180,26 +230,31 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
180
230
  // The panel the current request read its selected cell from, if the
181
231
  // notebook was open: a code answer may replace that cell only there.
182
232
  let readFrom: NotebookPanel | null = null;
183
- const router = new ChatRouter(settings, async () => {
184
- const open = await pin.current();
185
- readFrom = open;
186
- if (open) {
187
- return snapshotOf(open.content);
188
- }
189
- const path = pin.path();
190
- if (!path) {
191
- return null;
192
- }
193
- try {
194
- const file = await app.serviceManager.contents.get(path, {
195
- content: true
196
- });
197
- return snapshotFromIpynb(file.content);
198
- } catch {
199
- // Gone: an answer's cells will go into a new notebook instead.
200
- return null;
201
- }
202
- });
233
+ const router = new ChatRouter(
234
+ settings,
235
+ app.commands,
236
+ async () => {
237
+ const open = await pin.current();
238
+ readFrom = open;
239
+ if (open) {
240
+ return snapshotOf(open.content);
241
+ }
242
+ const path = pin.path();
243
+ if (!path) {
244
+ return null;
245
+ }
246
+ try {
247
+ const file = await app.serviceManager.contents.get(path, {
248
+ content: true
249
+ });
250
+ return snapshotFromIpynb(file.content);
251
+ } catch {
252
+ // Gone: an answer's cells will go into a new notebook instead.
253
+ return null;
254
+ }
255
+ },
256
+ () => auth?.urls?.ai ?? ''
257
+ );
203
258
  // Asked only when an answer has blocks to place: brings the
204
259
  // conversation's notebook to the front, opening it again if closed.
205
260
  const handoff = new KernelHandoff(
@@ -335,4 +390,15 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
335
390
  }
336
391
  };
337
392
 
338
- export default [datamesh_connect_extension, oceanum_ai_extension];
393
+ /**
394
+ * The sign-in contract, exported so another extension can provide or consume it: a Lumino
395
+ * token is matched by object identity, so everyone has to import this one object, and
396
+ * JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
397
+ */
398
+ export * from './auth/tokens';
399
+
400
+ export default [
401
+ deviceAuthPlugin,
402
+ datamesh_connect_extension,
403
+ oceanum_ai_extension
404
+ ];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Reading the Oceanum spec store's `notebook` records.
3
+ *
4
+ * Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
5
+ * share-oceanum package has a fuller client (create, update, permissions), and when
6
+ * sharing moves here the two should become one rather than sitting side by side.
7
+ */
8
+
9
+ /** A spec store list entry. The list omits the notebook body. */
10
+ export interface ISpecSummary {
11
+ id: string;
12
+ name: string;
13
+ description: string | null;
14
+ /** Set when the record is created and never updated, so this is a creation time. */
15
+ modified: string;
16
+ /** Null unless the caller created the record: the store hides other creators. */
17
+ creator: string | null;
18
+ }
19
+
20
+ function isSummary(value: unknown): value is ISpecSummary {
21
+ if (typeof value !== 'object' || value === null) {
22
+ return false;
23
+ }
24
+ const v = value as Record<string, unknown>;
25
+ return (
26
+ typeof v.id === 'string' &&
27
+ typeof v.name === 'string' &&
28
+ typeof v.modified === 'string'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Every notebook the signed-in user may read: their own and those shared with them.
34
+ *
35
+ * The store answers an unauthenticated caller with an empty list rather than an error,
36
+ * so a missing token is indistinguishable from an empty account — callers should check
37
+ * they are signed in before showing "no notebooks".
38
+ */
39
+ export async function listNotebooks(
40
+ specsUrl: string,
41
+ token: string,
42
+ fetcher: typeof fetch = fetch
43
+ ): Promise<ISpecSummary[]> {
44
+ const url = `${specsUrl.replace(/\/+$/, '')}/specs/notebook`;
45
+ const response = await fetcher(url, {
46
+ headers: { Authorization: `Bearer ${token}` }
47
+ });
48
+ if (!response.ok) {
49
+ // The store answers a bad or expired token with 400, and 403 both for no access
50
+ // and for a record that does not exist.
51
+ throw new Error(`Oceanum.io returned HTTP ${response.status}.`);
52
+ }
53
+ const data = await response.json();
54
+ if (!Array.isArray(data)) {
55
+ throw new Error('Oceanum.io returned an unexpected response.');
56
+ }
57
+ return data.filter(isSummary);
58
+ }
59
+
60
+ /**
61
+ * Split a listing into the caller's own notebooks and the rest, newest first.
62
+ *
63
+ * The store only sends `creator` on records the caller created, so anything else it
64
+ * returns is shared with them — directly, or because it is public. Addresses are
65
+ * compared case-insensitively: the store matches entities as case-sensitive globs, so
66
+ * the two sides can differ in case.
67
+ */
68
+ export function partitionNotebooks(
69
+ items: readonly ISpecSummary[],
70
+ email: string | null
71
+ ): { mine: ISpecSummary[]; shared: ISpecSummary[] } {
72
+ const me = email?.toLowerCase() ?? null;
73
+ const sorted = [...items].sort((a, b) =>
74
+ b.modified.localeCompare(a.modified)
75
+ );
76
+ const isMine = (item: ISpecSummary): boolean =>
77
+ me !== null && item.creator?.toLowerCase() === me;
78
+ return {
79
+ mine: sorted.filter(isMine),
80
+ shared: sorted.filter(item => !isMine(item))
81
+ };
82
+ }