@oceanum/oceanumlab 4.7.0 → 4.7.2

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 (81) 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 +18 -2
  5. package/lib/DatameshWidget.js +80 -12
  6. package/lib/StoredNotebooks.d.ts +20 -0
  7. package/lib/StoredNotebooks.js +112 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  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 +254 -0
  20. package/lib/auth/startSignIn.d.ts +11 -0
  21. package/lib/auth/startSignIn.js +16 -0
  22. package/lib/auth/tokens.d.ts +102 -0
  23. package/lib/auth/tokens.js +9 -0
  24. package/lib/chatRouter.d.ts +13 -1
  25. package/lib/chatRouter.js +9 -2
  26. package/lib/constants.d.ts +1 -1
  27. package/lib/constants.js +1 -1
  28. package/lib/datameshUiUrl.d.ts +2 -2
  29. package/lib/datameshUiUrl.js +6 -6
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +58 -17
  32. package/lib/share/client.d.ts +84 -0
  33. package/lib/share/client.js +205 -0
  34. package/lib/share/events.d.ts +7 -0
  35. package/lib/share/events.js +9 -0
  36. package/lib/share/notebook.d.ts +184 -0
  37. package/lib/share/notebook.js +277 -0
  38. package/lib/share/oceanumOnly.d.ts +49 -0
  39. package/lib/share/oceanumOnly.js +90 -0
  40. package/lib/share/plugin.d.ts +21 -0
  41. package/lib/share/plugin.js +883 -0
  42. package/lib/share/widgets.d.ts +25 -0
  43. package/lib/share/widgets.js +154 -0
  44. package/package.json +13 -1
  45. package/schema/datamesh-connect.json +3 -12
  46. package/src/DatameshUI.tsx +1 -10
  47. package/src/DatameshWidget.tsx +143 -19
  48. package/src/StoredNotebooks.tsx +206 -0
  49. package/src/Tabs.tsx +102 -0
  50. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  51. package/src/__tests__/OceanumPanel.spec.tsx +157 -0
  52. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  53. package/src/__tests__/Tabs.spec.tsx +168 -0
  54. package/src/__tests__/aiBackend.spec.ts +5 -6
  55. package/src/__tests__/chatRouter.spec.ts +9 -15
  56. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  57. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  58. package/src/__tests__/pluginArity.spec.ts +24 -0
  59. package/src/__tests__/serverAuth.spec.ts +277 -0
  60. package/src/__tests__/settingsSchema.spec.ts +73 -0
  61. package/src/__tests__/shareClient.spec.ts +314 -0
  62. package/src/__tests__/shareNotebook.spec.ts +451 -0
  63. package/src/__tests__/sharePlugin.spec.ts +426 -0
  64. package/src/aiBackend.ts +1 -1
  65. package/src/auth/accountWidget.ts +113 -0
  66. package/src/auth/claims.ts +30 -0
  67. package/src/auth/plugin.ts +155 -0
  68. package/src/auth/serverAuth.ts +342 -0
  69. package/src/auth/startSignIn.ts +23 -0
  70. package/src/auth/tokens.ts +111 -0
  71. package/src/chatRouter.ts +8 -4
  72. package/src/constants.ts +1 -1
  73. package/src/datameshUiUrl.ts +6 -6
  74. package/src/index.ts +87 -36
  75. package/src/share/client.ts +288 -0
  76. package/src/share/events.ts +10 -0
  77. package/src/share/notebook.ts +416 -0
  78. package/src/share/oceanumOnly.ts +126 -0
  79. package/src/share/plugin.ts +1096 -0
  80. package/src/share/widgets.ts +217 -0
  81. package/style/index.css +266 -13
@@ -0,0 +1,155 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
6
+ import { PageConfig, URLExt } from '@jupyterlab/coreutils';
7
+ import { ServerConnection } from '@jupyterlab/services';
8
+
9
+ import { AccountWidget, CommandIDs, SignInBody } from './accountWidget';
10
+ import {
11
+ AuthRequest,
12
+ readServerAuthOption,
13
+ SERVER_AUTH_OPTION,
14
+ ServerAuth
15
+ } from './serverAuth';
16
+ import { IOceanumAuth } from './tokens';
17
+
18
+ /**
19
+ * The id other deployments disable. notebook.oceanum.io provides IOceanumAuth from its own
20
+ * Oceanum widget extension, and Lumino lets a second provider silently replace the first
21
+ * rather than refusing it, so that site lists this id under `disabledExtensions`.
22
+ */
23
+ export const DEVICE_AUTH_PLUGIN_ID = '@oceanum/oceanumlab:device-auth';
24
+
25
+ function serverRequest(settings: ServerConnection.ISettings): AuthRequest {
26
+ return async (action, method) => {
27
+ const url = URLExt.join(settings.baseUrl, 'oceanum', 'auth', action);
28
+ const response = await ServerConnection.makeRequest(
29
+ url,
30
+ { method },
31
+ settings
32
+ );
33
+ if (!response.ok) {
34
+ throw new ServerConnection.ResponseError(response);
35
+ }
36
+ return response.json();
37
+ };
38
+ }
39
+
40
+ /** Show the code and the link until the sign-in completes, fails, or is cancelled. */
41
+ async function showSignIn(auth: ServerAuth): Promise<void> {
42
+ const pending = auth.pending;
43
+ if (!pending) {
44
+ return;
45
+ }
46
+ const dialog = new Dialog({
47
+ title: 'Sign in to Oceanum.io',
48
+ body: new SignInBody(pending),
49
+ buttons: [Dialog.cancelButton()]
50
+ });
51
+ const closeWhenSettled = (): void => {
52
+ if (auth.pending === null) {
53
+ dialog.reject();
54
+ }
55
+ };
56
+ auth.stateChanged.connect(closeWhenSettled);
57
+ auth.userChanged.connect(closeWhenSettled);
58
+ try {
59
+ await dialog.launch();
60
+ } finally {
61
+ auth.stateChanged.disconnect(closeWhenSettled);
62
+ auth.userChanged.disconnect(closeWhenSettled);
63
+ }
64
+ if (auth.pending) {
65
+ // Still waiting, so it was the user who closed it.
66
+ await auth.cancelSignIn();
67
+ } else if (!auth.user && auth.error) {
68
+ Notification.warning(`Oceanum.io sign-in did not complete: ${auth.error}`, {
69
+ autoClose: false
70
+ });
71
+ }
72
+ }
73
+
74
+ export const deviceAuthPlugin: JupyterFrontEndPlugin<IOceanumAuth> = {
75
+ id: DEVICE_AUTH_PLUGIN_ID,
76
+ description:
77
+ 'Oceanum.io sign-in by device code, run by the Jupyter server extension.',
78
+ autoStart: true,
79
+ provides: IOceanumAuth,
80
+ optional: [ICommandPalette],
81
+ activate: (
82
+ app: JupyterFrontEnd,
83
+ palette: ICommandPalette | null
84
+ ): IOceanumAuth => {
85
+ const environment = readServerAuthOption(
86
+ PageConfig.getOption(SERVER_AUTH_OPTION),
87
+ window.location.hostname
88
+ );
89
+ const auth = new ServerAuth({
90
+ environment,
91
+ request: serverRequest(app.serviceManager.serverSettings),
92
+ onSessionEnded: message =>
93
+ Notification.warning(message, { autoClose: false })
94
+ });
95
+ if (!environment) {
96
+ // The server offers no sign-in (`c.OceanumLab.sign_in = "off"`), or there is no
97
+ // oceanumlab server extension at all. Consumers see an auth with no environment and
98
+ // say sign-in is not configured; nothing is added to the top bar.
99
+ return auth;
100
+ }
101
+
102
+ document.addEventListener('visibilitychange', () => {
103
+ if (document.visibilityState === 'visible') {
104
+ void auth.refresh();
105
+ }
106
+ });
107
+
108
+ const { commands } = app;
109
+ let dialogOpen = false;
110
+ commands.addCommand(CommandIDs.signIn, {
111
+ label: 'Sign in to Oceanum.io',
112
+ isVisible: () => !auth.user,
113
+ execute: async () => {
114
+ await auth.signIn();
115
+ if (auth.pending && !dialogOpen) {
116
+ dialogOpen = true;
117
+ try {
118
+ await showSignIn(auth);
119
+ } finally {
120
+ dialogOpen = false;
121
+ }
122
+ } else if (!auth.user && !auth.pending && auth.error) {
123
+ Notification.warning(
124
+ `Oceanum.io sign-in could not start: ${auth.error}`,
125
+ { autoClose: false }
126
+ );
127
+ }
128
+ }
129
+ });
130
+ commands.addCommand(CommandIDs.signOut, {
131
+ label: 'Sign out of Oceanum.io',
132
+ isVisible: () => auth.user !== null,
133
+ execute: () => auth.signOut()
134
+ });
135
+ commands.addCommand(CommandIDs.account, {
136
+ label: () =>
137
+ auth.user ? `Signed in as ${auth.user.email}` : 'Not signed in',
138
+ isEnabled: () => false,
139
+ execute: () => undefined
140
+ });
141
+ commands.addCommand(CommandIDs.accessToken, {
142
+ label: 'Oceanum.io access token',
143
+ isVisible: () => false,
144
+ execute: () => auth.getAccessToken().catch((): null => null)
145
+ });
146
+ auth.userChanged.connect(() => commands.notifyCommandChanged());
147
+
148
+ if (palette) {
149
+ palette.addItem({ command: CommandIDs.signIn, category: 'Oceanum' });
150
+ palette.addItem({ command: CommandIDs.signOut, category: 'Oceanum' });
151
+ }
152
+ app.shell.add(new AccountWidget(auth, commands), 'top', { rank: 1000 });
153
+ return auth;
154
+ }
155
+ };
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Oceanum.io sign-in on an ordinary JupyterLab, where the Jupyter server runs the OAuth
3
+ * device grant (oceanumlab/auth.py) and this only reflects its state.
4
+ *
5
+ * A browser sign-in cannot work here. Popup, redirect and the silent iframe check all send
6
+ * the page's own origin to Auth0 as the callback, Auth0 only accepts addresses registered in
7
+ * advance, and it cannot wildcard a port or an arbitrary host -- so everywhere but Oceanum's
8
+ * own sites the answer is "Callback URL mismatch". The device grant has no callback: the
9
+ * user opens a link and confirms a code, and the server collects the tokens.
10
+ *
11
+ * The refresh token and the device code never reach this page. It is handed short-lived
12
+ * access tokens only.
13
+ */
14
+ import { PromiseDelegate } from '@lumino/coreutils';
15
+ import { IDisposable } from '@lumino/disposable';
16
+ import { ISignal, Signal } from '@lumino/signaling';
17
+
18
+ import { userFromClaims } from './claims';
19
+ import {
20
+ IOceanumAuth,
21
+ IOceanumEnvironment,
22
+ IOceanumServiceUrls,
23
+ IOceanumUser
24
+ } from './tokens';
25
+
26
+ /**
27
+ * The page option the server extension publishes when it runs the device sign-in. Must
28
+ * match SERVER_AUTH_OPTION in oceanumlab/__init__.py.
29
+ */
30
+ export const SERVER_AUTH_OPTION = 'oceanumServerAuth';
31
+
32
+ /** A sign-in waiting on the user: the code to confirm, and where to confirm it. */
33
+ export interface IPendingSignIn {
34
+ readonly userCode: string;
35
+ readonly verificationUri: string;
36
+ /** The verification page with the code already filled in. */
37
+ readonly verificationUriComplete: string;
38
+ /** Seconds until the code lapses. */
39
+ readonly expiresIn: number;
40
+ }
41
+
42
+ interface IServerStatus {
43
+ state: 'signedOut' | 'pending' | 'signedIn';
44
+ claims: Record<string, unknown> | null;
45
+ pending: IPendingSignIn | null;
46
+ error: string | null;
47
+ accessToken?: string | null;
48
+ }
49
+
50
+ /** One exchange with the server extension's `/oceanum/auth/<action>` handler. */
51
+ export type AuthRequest = (
52
+ action: 'status' | 'token' | 'start' | 'cancel' | 'signout',
53
+ method: 'GET' | 'POST'
54
+ ) => Promise<unknown>;
55
+
56
+ function httpUrl(value: unknown): string | null {
57
+ if (typeof value !== 'string' || value.trim() === '') {
58
+ return null;
59
+ }
60
+ try {
61
+ const url = new URL(value);
62
+ return url.protocol === 'https:' || url.protocol === 'http:'
63
+ ? url.href.replace(/\/+$/, '')
64
+ : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The Oceanum deployment the server signs in to, from the page option, or `null` when the
72
+ * server offers no device sign-in (or the option is malformed, which is treated the same).
73
+ */
74
+ export function readServerAuthOption(
75
+ option: string | undefined,
76
+ hostname: string
77
+ ): IOceanumEnvironment | null {
78
+ if (!option) {
79
+ return null;
80
+ }
81
+ let raw: unknown;
82
+ try {
83
+ raw = JSON.parse(option);
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
88
+ return null;
89
+ }
90
+ const config = raw as Record<string, unknown>;
91
+ const urlsIn = (config.urls ?? {}) as Record<string, unknown>;
92
+ const datamesh = httpUrl(urlsIn.datamesh);
93
+ const specs = httpUrl(urlsIn.specs);
94
+ const manage = httpUrl(urlsIn.manage);
95
+ if (
96
+ typeof config.auth0Domain !== 'string' ||
97
+ typeof config.clientId !== 'string' ||
98
+ !datamesh ||
99
+ !specs ||
100
+ !manage
101
+ ) {
102
+ return null;
103
+ }
104
+ const urls: IOceanumServiceUrls = {
105
+ datamesh,
106
+ specs,
107
+ manage,
108
+ ...(httpUrl(urlsIn.datameshUi)
109
+ ? { datameshUi: httpUrl(urlsIn.datameshUi) as string }
110
+ : {}),
111
+ ...(httpUrl(urlsIn.ai) ? { ai: httpUrl(urlsIn.ai) as string } : {})
112
+ };
113
+ return {
114
+ // The device grant is indifferent to where the page is served from, so this is simply
115
+ // wherever that is, rather than a list to match against.
116
+ hosts: [hostname.toLowerCase()],
117
+ auth0Domain: config.auth0Domain,
118
+ clientId: config.clientId,
119
+ oceanumDomain:
120
+ typeof config.oceanumDomain === 'string' ? config.oceanumDomain : '',
121
+ signInRedirect: false,
122
+ fileManagement: config.fileManagement === 'oceanum' ? 'oceanum' : 'local',
123
+ urls
124
+ };
125
+ }
126
+
127
+ export interface IServerAuthOptions {
128
+ /** The deployment the server signs in to, or `null` when it offers no sign-in. */
129
+ environment: IOceanumEnvironment | null;
130
+ request: AuthRequest;
131
+ /** Called when a session ends other than by `signOut()`. */
132
+ onSessionEnded?: (message: string) => void;
133
+ /** How often to ask the server whether a pending sign-in has completed. */
134
+ pendingPollMs?: number;
135
+ /** How often to pick up a refreshed access token while signed in. */
136
+ tokenPollMs?: number;
137
+ }
138
+
139
+ export class ServerAuth implements IOceanumAuth, IDisposable {
140
+ constructor(options: IServerAuthOptions) {
141
+ this.environment = options.environment;
142
+ this._request = options.request;
143
+ this._onSessionEnded = options.onSessionEnded;
144
+ this._pendingPollMs = options.pendingPollMs ?? 2000;
145
+ this._tokenPollMs = options.tokenPollMs ?? 60000;
146
+ if (!this.environment) {
147
+ this._ready.resolve();
148
+ return;
149
+ }
150
+ // A server that signed in before this page loaded (a reload, a second tab) is already
151
+ // signed in; `ready` waits for that answer so nothing downstream sees a false sign-out.
152
+ void this._sync('token').finally(() => this._ready.resolve());
153
+ }
154
+
155
+ readonly environment: IOceanumEnvironment | null;
156
+
157
+ get urls(): IOceanumServiceUrls | null {
158
+ return this.environment?.urls ?? null;
159
+ }
160
+
161
+ get ready(): Promise<void> {
162
+ return this._ready.promise;
163
+ }
164
+
165
+ get user(): IOceanumUser | null {
166
+ return this._user;
167
+ }
168
+
169
+ get userChanged(): ISignal<IOceanumAuth, IOceanumUser | null> {
170
+ return this._userChanged;
171
+ }
172
+
173
+ get tokenChanged(): ISignal<IOceanumAuth, string | null> {
174
+ return this._tokenChanged;
175
+ }
176
+
177
+ /** The sign-in waiting on the user, if any. */
178
+ get pending(): IPendingSignIn | null {
179
+ return this._pending;
180
+ }
181
+
182
+ /** Why the last sign-in attempt ended, if it failed. */
183
+ get error(): string | null {
184
+ return this._error;
185
+ }
186
+
187
+ /** Emits when `pending` or `error` changes. */
188
+ get stateChanged(): ISignal<ServerAuth, void> {
189
+ return this._stateChanged;
190
+ }
191
+
192
+ async signIn(): Promise<void> {
193
+ if (!this.environment || this._user) {
194
+ return;
195
+ }
196
+ await this._sync('start', 'POST');
197
+ }
198
+
199
+ /** Abandon a sign-in that is waiting on the user. */
200
+ async cancelSignIn(): Promise<void> {
201
+ if (this._pending) {
202
+ await this._sync('cancel', 'POST');
203
+ }
204
+ }
205
+
206
+ async signOut(): Promise<void> {
207
+ this._signingOut = true;
208
+ try {
209
+ await this._sync('signout', 'POST');
210
+ } finally {
211
+ this._signingOut = false;
212
+ }
213
+ }
214
+
215
+ async getAccessToken(): Promise<string | null> {
216
+ if (!this.environment || !this._user) {
217
+ return null;
218
+ }
219
+ await this._sync('token');
220
+ return this._token;
221
+ }
222
+
223
+ /** Re-read the server's state, e.g. when the tab becomes visible again. */
224
+ async refresh(): Promise<void> {
225
+ if (this.environment) {
226
+ await this._sync(this._user ? 'token' : 'status');
227
+ }
228
+ }
229
+
230
+ get isDisposed(): boolean {
231
+ return this._disposed;
232
+ }
233
+
234
+ dispose(): void {
235
+ if (this._disposed) {
236
+ return;
237
+ }
238
+ this._disposed = true;
239
+ this._clearTimer();
240
+ Signal.clearData(this);
241
+ }
242
+
243
+ private async _sync(
244
+ action: 'status' | 'token' | 'start' | 'cancel' | 'signout',
245
+ method: 'GET' | 'POST' = 'GET'
246
+ ): Promise<void> {
247
+ let status: IServerStatus;
248
+ try {
249
+ status = (await this._request(action, method)) as IServerStatus;
250
+ } catch (reason) {
251
+ // The Jupyter server being briefly unreachable is not a sign-out: keep what we have
252
+ // and let the next poll find out.
253
+ console.warn(`Oceanum.io sign-in: ${action} failed`, reason);
254
+ this._schedule();
255
+ return;
256
+ }
257
+ if (this._disposed || !status || typeof status.state !== 'string') {
258
+ return;
259
+ }
260
+ this._apply(status);
261
+ if (status.state === 'signedIn' && action !== 'token') {
262
+ // A sign-in that has just completed: fetch the token it produced.
263
+ await this._sync('token');
264
+ }
265
+ }
266
+
267
+ private _apply(status: IServerStatus): void {
268
+ const user =
269
+ status.state === 'signedIn' ? userFromClaims(status.claims) : null;
270
+ const pending = status.state === 'pending' ? status.pending : null;
271
+ const error = status.error ?? null;
272
+
273
+ if (
274
+ pending?.userCode !== this._pending?.userCode ||
275
+ error !== this._error
276
+ ) {
277
+ this._pending = pending;
278
+ this._error = error;
279
+ this._stateChanged.emit();
280
+ }
281
+
282
+ const wasSignedIn = this._user !== null;
283
+ if ((user?.sub ?? null) !== (this._user?.sub ?? null)) {
284
+ this._user = user;
285
+ this._userChanged.emit(user);
286
+ if (wasSignedIn && !user && !this._signingOut) {
287
+ this._onSessionEnded?.(
288
+ error ?? 'Your Oceanum.io session has ended. Sign in again.'
289
+ );
290
+ }
291
+ }
292
+
293
+ const token = user ? (status.accessToken ?? this._token) : null;
294
+ if (token !== this._token) {
295
+ this._token = token;
296
+ this._tokenChanged.emit(token);
297
+ }
298
+ this._schedule();
299
+ }
300
+
301
+ /** Poll quickly while a sign-in is pending, slowly while signed in, not at all otherwise. */
302
+ private _schedule(): void {
303
+ this._clearTimer();
304
+ if (this._disposed) {
305
+ return;
306
+ }
307
+ if (this._pending) {
308
+ this._timer = setTimeout(
309
+ () => void this._sync('status'),
310
+ this._pendingPollMs
311
+ );
312
+ } else if (this._user) {
313
+ this._timer = setTimeout(
314
+ () => void this._sync('token'),
315
+ this._tokenPollMs
316
+ );
317
+ }
318
+ }
319
+
320
+ private _clearTimer(): void {
321
+ if (this._timer !== null) {
322
+ clearTimeout(this._timer);
323
+ this._timer = null;
324
+ }
325
+ }
326
+
327
+ private _request: AuthRequest;
328
+ private _onSessionEnded: ((message: string) => void) | undefined;
329
+ private _pendingPollMs: number;
330
+ private _tokenPollMs: number;
331
+ private _ready = new PromiseDelegate<void>();
332
+ private _user: IOceanumUser | null = null;
333
+ private _token: string | null = null;
334
+ private _pending: IPendingSignIn | null = null;
335
+ private _error: string | null = null;
336
+ private _signingOut = false;
337
+ private _disposed = false;
338
+ private _timer: ReturnType<typeof setTimeout> | null = null;
339
+ private _userChanged = new Signal<IOceanumAuth, IOceanumUser | null>(this);
340
+ private _tokenChanged = new Signal<IOceanumAuth, string | null>(this);
341
+ private _stateChanged = new Signal<ServerAuth, void>(this);
342
+ }
@@ -0,0 +1,23 @@
1
+ import type { CommandRegistry } from '@lumino/commands';
2
+
3
+ import { CommandIDs } from './accountWidget';
4
+ import type { IOceanumAuth } from './tokens';
5
+
6
+ /**
7
+ * Start signing in the way this host shows it.
8
+ *
9
+ * On a JupyterLab server the device sign-in command both starts the flow and shows the
10
+ * code dialog; `auth.signIn()` alone starts the flow and shows nothing, so the user is
11
+ * left with a "Signing in…" top bar and no code. notebook.oceanum.io disables that
12
+ * command and provides an auth whose `signIn()` opens the Oceanum sign-in itself.
13
+ */
14
+ export async function startSignIn(
15
+ commands: CommandRegistry,
16
+ auth: IOceanumAuth
17
+ ): Promise<void> {
18
+ if (commands.hasCommand(CommandIDs.signIn)) {
19
+ await commands.execute(CommandIDs.signIn);
20
+ return;
21
+ }
22
+ await auth.signIn();
23
+ }
@@ -0,0 +1,111 @@
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
+ * Whether Oceanum.io is the only file management this host shows, as on
30
+ * notebook.oceanum.io: the file browser and the File menu's local-file entries go,
31
+ * and every local save is pushed to the spec store. `'local'` (the default when
32
+ * absent) keeps JupyterLab's file management alongside the Oceanum commands.
33
+ */
34
+ readonly fileManagement?: 'local' | 'oceanum';
35
+ }
36
+
37
+ /** Service base URLs for an environment, without trailing slashes. */
38
+ export interface IOceanumServiceUrls {
39
+ /** Datamesh gateway, e.g. `https://datamesh.oceanum.io`. */
40
+ readonly datamesh: string;
41
+ /** Spec store, e.g. `https://specs.oceanum.io`. */
42
+ readonly specs: string;
43
+ /** User management service (Auth0 user metadata), e.g. `https://manage.oceanum.io`. */
44
+ readonly manage: string;
45
+ /**
46
+ * Datamesh UI, e.g. `https://ui.datamesh.oceanum.io`, shown by oceanumlab's Datamesh panel.
47
+ * Optional: without it, Oceanum production applies.
48
+ */
49
+ readonly datameshUi?: string;
50
+ /**
51
+ * Oceanum AI backend, e.g. `https://ai.oceanum.io`, called by oceanumlab's AI chat.
52
+ * Optional: without it, Oceanum production applies.
53
+ */
54
+ readonly ai?: string;
55
+ }
56
+
57
+ export type ColorScheme = 'light' | 'dark' | 'auto';
58
+
59
+ /** The signed-in user, from the Auth0 ID token claims. */
60
+ export interface IOceanumUser {
61
+ readonly sub: string;
62
+ readonly email: string;
63
+ readonly name: string | null;
64
+ /** `https://oceanum.io/active_org`. */
65
+ readonly activeOrg: string | null;
66
+ /** `https://oceanum.io/color_scheme`. */
67
+ readonly colorScheme: ColorScheme | null;
68
+ }
69
+
70
+ /**
71
+ * Oceanum.io sign-in state, shared with other Oceanum Notebook extensions.
72
+ */
73
+ export interface IOceanumAuth {
74
+ /** The environment for this page, or `null` if sign-in is not configured for this host. */
75
+ readonly environment: IOceanumEnvironment | null;
76
+ /** Service URLs for `environment`, or `null` when it is `null`. */
77
+ readonly urls: IOceanumServiceUrls | null;
78
+ /** Resolves once any login redirect has been handled and the session restored. */
79
+ readonly ready: Promise<void>;
80
+ /** The signed-in user, or `null`. */
81
+ readonly user: IOceanumUser | null;
82
+ /** Emits the new user (or `null`) on sign-in and sign-out. */
83
+ readonly userChanged: ISignal<IOceanumAuth, IOceanumUser | null>;
84
+ /** Emits the new access token (or `null`) whenever it changes, including refreshes. */
85
+ readonly tokenChanged: ISignal<IOceanumAuth, string | null>;
86
+ /**
87
+ * Start sign-in without leaving the page, so its kernels survive. Resolves once sign-in has
88
+ * started, not when it completes; watch `userChanged` for the result. Does nothing while a
89
+ * user is signed in.
90
+ */
91
+ signIn(): Promise<void>;
92
+ /** Sign out of Oceanum.io and clear the session. */
93
+ signOut(): Promise<void>;
94
+ /**
95
+ * A current access token, refreshed if it is near expiry, or `null` when signed out.
96
+ * Never cache the result: Auth0 access tokens are short-lived.
97
+ */
98
+ getAccessToken(): Promise<string | null>;
99
+ }
100
+
101
+ /**
102
+ * The sign-in contract. oceanumlab owns it and exports it from its package entry, because a
103
+ * Lumino token is matched by object identity: every extension that provides or consumes it
104
+ * has to import this one object, which JupyterLab arranges by sharing `@oceanum/oceanumlab`
105
+ * as a singleton module. On an ordinary JupyterLab oceanumlab's own device sign-in provides
106
+ * it; on notebook.oceanum.io the notebook's Oceanum widget extension does.
107
+ */
108
+ export const IOceanumAuth = new Token<IOceanumAuth>(
109
+ '@oceanum/oceanumlab:IOceanumAuth',
110
+ 'Oceanum.io sign-in state and Datamesh access tokens.'
111
+ );
package/src/chatRouter.ts CHANGED
@@ -92,7 +92,13 @@ export class ChatRouter {
92
92
  constructor(
93
93
  private _settings: ISettingRegistry.ISettings,
94
94
  private _commands: AuthCommands,
95
- private _notebook: () => Promise<INotebookSnapshot | null>
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 = () => ''
96
102
  ) {}
97
103
 
98
104
  async route(
@@ -200,9 +206,7 @@ export class ChatRouter {
200
206
  : 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
201
207
  );
202
208
  }
203
- const backend = aiBackendUrl(
204
- this._settings.get('aiBackendUrl').composite as string
205
- );
209
+ const backend = aiBackendUrl(this._backend());
206
210
 
207
211
  let response: Response;
208
212
  try {
package/src/constants.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * These values are compiled into the extension.
4
4
  */
5
5
 
6
- /** Default URL of the Oceanum AI backend service (the `aiBackendUrl` setting) */
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,22 +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
10
  import { validHttpUrl } from './httpUrl';
11
11
 
12
- /** The Datamesh UI address to use for a `datameshUiUrl` setting value. */
12
+ /** The Datamesh UI address to use for a configured value. */
13
13
  export function validDatameshUiUrl(url: string | null | undefined): URL {
14
14
  return validHttpUrl(url, DATAMESH_UI_SERVICE);
15
15
  }
16
16
 
17
17
  /**
18
18
  * The panel's iframe `src`: the Datamesh UI in embed mode, which hides the
19
- * Oceanum.io navigator. Any query the setting already has is kept.
19
+ * Oceanum.io navigator. Any query the address already has is kept.
20
20
  */
21
21
  export function datameshUiSrc(url: string | null | undefined): string {
22
22
  const src = validDatameshUiUrl(url);