@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.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +24 -2
- package/lib/DatameshWidget.js +171 -45
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +17 -1
- package/lib/chatRouter.js +34 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +8 -16
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +67 -15
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -6
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +294 -59
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +393 -0
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +271 -0
- package/src/__tests__/chatRouter.spec.ts +253 -17
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +150 -0
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +41 -9
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +8 -15
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +99 -33
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthRequest,
|
|
3
|
+
readServerAuthOption,
|
|
4
|
+
SERVER_AUTH_OPTION,
|
|
5
|
+
ServerAuth
|
|
6
|
+
} from '../auth/serverAuth';
|
|
7
|
+
import { IOceanumEnvironment } from '../auth/tokens';
|
|
8
|
+
|
|
9
|
+
const OPTION = JSON.stringify({
|
|
10
|
+
auth0Domain: 'auth.oceanum.io',
|
|
11
|
+
clientId: 'a-client',
|
|
12
|
+
oceanumDomain: 'oceanum.io',
|
|
13
|
+
urls: {
|
|
14
|
+
datamesh: 'https://datamesh.oceanum.io/',
|
|
15
|
+
specs: 'https://specs.oceanum.io',
|
|
16
|
+
manage: 'https://manage.oceanum.io',
|
|
17
|
+
ai: 'https://ai.oceanum.io'
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const ENVIRONMENT = readServerAuthOption(
|
|
22
|
+
OPTION,
|
|
23
|
+
'localhost'
|
|
24
|
+
) as IOceanumEnvironment;
|
|
25
|
+
|
|
26
|
+
const CLAIMS = { sub: 'auth0|abc', email: 'ada@example.org', name: 'Ada' };
|
|
27
|
+
const PENDING = {
|
|
28
|
+
userCode: 'WXYZ-1234',
|
|
29
|
+
verificationUri: 'https://auth.oceanum.io/activate',
|
|
30
|
+
verificationUriComplete:
|
|
31
|
+
'https://auth.oceanum.io/activate?user_code=WXYZ-1234',
|
|
32
|
+
expiresIn: 900
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const SIGNED_OUT = {
|
|
36
|
+
state: 'signedOut',
|
|
37
|
+
claims: null as unknown,
|
|
38
|
+
pending: null as unknown,
|
|
39
|
+
error: null as string | null
|
|
40
|
+
};
|
|
41
|
+
const WAITING = { ...SIGNED_OUT, state: 'pending', pending: PENDING };
|
|
42
|
+
const SIGNED_IN = { ...SIGNED_OUT, state: 'signedIn', claims: CLAIMS };
|
|
43
|
+
|
|
44
|
+
/** A Jupyter server whose answers can be changed as the test goes. */
|
|
45
|
+
function server(initial: Record<string, unknown>) {
|
|
46
|
+
const calls: string[] = [];
|
|
47
|
+
const state = { status: initial, token: 'jwt-1' as string | null };
|
|
48
|
+
const request: AuthRequest = async (action, method) => {
|
|
49
|
+
calls.push(`${method} ${action}`);
|
|
50
|
+
if (action === 'token') {
|
|
51
|
+
const signedIn = state.status.state === 'signedIn';
|
|
52
|
+
return { ...state.status, accessToken: signedIn ? state.token : null };
|
|
53
|
+
}
|
|
54
|
+
return state.status;
|
|
55
|
+
};
|
|
56
|
+
return { calls, state, request };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tick = (ms = 0): Promise<void> =>
|
|
60
|
+
new Promise(resolve => setTimeout(resolve, ms));
|
|
61
|
+
|
|
62
|
+
async function until(check: () => void, timeout = 2000): Promise<void> {
|
|
63
|
+
const end = Date.now() + timeout;
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
check();
|
|
67
|
+
return;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (Date.now() > end) {
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await tick(10);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('readServerAuthOption', () => {
|
|
78
|
+
it('reads the page option name the server publishes under', () => {
|
|
79
|
+
// oceanumlab/__init__.py writes this literal (SERVER_AUTH_OPTION there, pinned by its
|
|
80
|
+
// own test). Renaming either side alone would silently turn sign-in off everywhere.
|
|
81
|
+
expect(SERVER_AUTH_OPTION).toBe('oceanumServerAuth');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('builds the environment, normalising URLs and dropping absent optional ones', () => {
|
|
85
|
+
expect(ENVIRONMENT.urls).toEqual({
|
|
86
|
+
datamesh: 'https://datamesh.oceanum.io',
|
|
87
|
+
specs: 'https://specs.oceanum.io',
|
|
88
|
+
manage: 'https://manage.oceanum.io',
|
|
89
|
+
ai: 'https://ai.oceanum.io'
|
|
90
|
+
});
|
|
91
|
+
// The device grant is indifferent to the page's address, so there is nothing to match.
|
|
92
|
+
expect(ENVIRONMENT.hosts).toEqual(['localhost']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('treats a missing or unusable option as no sign-in on offer', () => {
|
|
96
|
+
expect(readServerAuthOption(undefined, 'localhost')).toBeNull();
|
|
97
|
+
expect(readServerAuthOption('', 'localhost')).toBeNull();
|
|
98
|
+
expect(readServerAuthOption('not json', 'localhost')).toBeNull();
|
|
99
|
+
expect(readServerAuthOption('[]', 'localhost')).toBeNull();
|
|
100
|
+
const noSpecs = JSON.parse(OPTION);
|
|
101
|
+
delete noSpecs.urls.specs;
|
|
102
|
+
expect(
|
|
103
|
+
readServerAuthOption(JSON.stringify(noSpecs), 'localhost')
|
|
104
|
+
).toBeNull();
|
|
105
|
+
const script = JSON.parse(OPTION);
|
|
106
|
+
script.urls.specs = 'javascript:alert(1)';
|
|
107
|
+
expect(
|
|
108
|
+
readServerAuthOption(JSON.stringify(script), 'localhost')
|
|
109
|
+
).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('ServerAuth', () => {
|
|
114
|
+
it('is inert, and ready, when the server offers no sign-in', async () => {
|
|
115
|
+
const jupyter = server(SIGNED_OUT);
|
|
116
|
+
const auth = new ServerAuth({
|
|
117
|
+
environment: null,
|
|
118
|
+
request: jupyter.request
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await auth.ready;
|
|
122
|
+
await auth.signIn();
|
|
123
|
+
|
|
124
|
+
expect(auth.user).toBeNull();
|
|
125
|
+
expect(await auth.getAccessToken()).toBeNull();
|
|
126
|
+
expect(jupyter.calls).toEqual([]);
|
|
127
|
+
auth.dispose();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('finds a session the server already holds, before it says it is ready', async () => {
|
|
131
|
+
// A page reload, or a second tab: the server signed in earlier and still is.
|
|
132
|
+
const jupyter = server(SIGNED_IN);
|
|
133
|
+
const auth = new ServerAuth({
|
|
134
|
+
environment: ENVIRONMENT,
|
|
135
|
+
request: jupyter.request
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await auth.ready;
|
|
139
|
+
|
|
140
|
+
expect(auth.user?.email).toBe('ada@example.org');
|
|
141
|
+
expect(await auth.getAccessToken()).toBe('jwt-1');
|
|
142
|
+
auth.dispose();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('starts a sign-in, waits on the user, then reports the user and the token', async () => {
|
|
146
|
+
const jupyter = server(SIGNED_OUT);
|
|
147
|
+
const auth = new ServerAuth({
|
|
148
|
+
environment: ENVIRONMENT,
|
|
149
|
+
request: jupyter.request,
|
|
150
|
+
pendingPollMs: 10
|
|
151
|
+
});
|
|
152
|
+
await auth.ready;
|
|
153
|
+
const users: (string | null)[] = [];
|
|
154
|
+
const tokens: (string | null)[] = [];
|
|
155
|
+
auth.userChanged.connect((_, user) => users.push(user?.email ?? null));
|
|
156
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
157
|
+
|
|
158
|
+
jupyter.state.status = WAITING;
|
|
159
|
+
await auth.signIn();
|
|
160
|
+
expect(auth.pending?.userCode).toBe('WXYZ-1234');
|
|
161
|
+
expect(auth.user).toBeNull();
|
|
162
|
+
|
|
163
|
+
// The user confirms the code in their browser; the server's next answer says so.
|
|
164
|
+
jupyter.state.status = SIGNED_IN;
|
|
165
|
+
await until(() => expect(auth.user?.email).toBe('ada@example.org'));
|
|
166
|
+
|
|
167
|
+
expect(auth.pending).toBeNull();
|
|
168
|
+
expect(users).toEqual(['ada@example.org']);
|
|
169
|
+
expect(tokens).toEqual(['jwt-1']);
|
|
170
|
+
auth.dispose();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('passes on a refreshed token while signed in', async () => {
|
|
174
|
+
const jupyter = server(SIGNED_IN);
|
|
175
|
+
const auth = new ServerAuth({
|
|
176
|
+
environment: ENVIRONMENT,
|
|
177
|
+
request: jupyter.request,
|
|
178
|
+
tokenPollMs: 10
|
|
179
|
+
});
|
|
180
|
+
await auth.ready;
|
|
181
|
+
const tokens: (string | null)[] = [];
|
|
182
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
183
|
+
|
|
184
|
+
jupyter.state.token = 'jwt-2';
|
|
185
|
+
await until(() => expect(tokens).toEqual(['jwt-2']));
|
|
186
|
+
auth.dispose();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('signs out without calling it a session that ended', async () => {
|
|
190
|
+
const jupyter = server(SIGNED_IN);
|
|
191
|
+
const ended: string[] = [];
|
|
192
|
+
const auth = new ServerAuth({
|
|
193
|
+
environment: ENVIRONMENT,
|
|
194
|
+
request: jupyter.request,
|
|
195
|
+
onSessionEnded: message => ended.push(message)
|
|
196
|
+
});
|
|
197
|
+
await auth.ready;
|
|
198
|
+
const tokens: (string | null)[] = [];
|
|
199
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
200
|
+
|
|
201
|
+
jupyter.state.status = SIGNED_OUT;
|
|
202
|
+
await auth.signOut();
|
|
203
|
+
|
|
204
|
+
expect(jupyter.calls).toContain('POST signout');
|
|
205
|
+
expect(auth.user).toBeNull();
|
|
206
|
+
expect(tokens).toEqual([null]);
|
|
207
|
+
expect(ended).toEqual([]);
|
|
208
|
+
auth.dispose();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('says so when the server ends the session itself', async () => {
|
|
212
|
+
const jupyter = server(SIGNED_IN);
|
|
213
|
+
const ended: string[] = [];
|
|
214
|
+
const auth = new ServerAuth({
|
|
215
|
+
environment: ENVIRONMENT,
|
|
216
|
+
request: jupyter.request,
|
|
217
|
+
onSessionEnded: message => ended.push(message),
|
|
218
|
+
tokenPollMs: 10
|
|
219
|
+
});
|
|
220
|
+
await auth.ready;
|
|
221
|
+
|
|
222
|
+
// The refresh token was revoked: the server's next answer is signed out, with why.
|
|
223
|
+
jupyter.state.status = {
|
|
224
|
+
...SIGNED_OUT,
|
|
225
|
+
error: 'Your Oceanum.io session has ended.'
|
|
226
|
+
};
|
|
227
|
+
await until(() => expect(auth.user).toBeNull());
|
|
228
|
+
|
|
229
|
+
expect(ended).toEqual(['Your Oceanum.io session has ended.']);
|
|
230
|
+
auth.dispose();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('keeps the session when the Jupyter server is briefly unreachable', async () => {
|
|
234
|
+
const jupyter = server(SIGNED_IN);
|
|
235
|
+
let failing = false;
|
|
236
|
+
const warn = jest
|
|
237
|
+
.spyOn(console, 'warn')
|
|
238
|
+
.mockImplementation(() => undefined);
|
|
239
|
+
const auth = new ServerAuth({
|
|
240
|
+
environment: ENVIRONMENT,
|
|
241
|
+
request: async (action, method) => {
|
|
242
|
+
if (failing) {
|
|
243
|
+
throw new Error('connection refused');
|
|
244
|
+
}
|
|
245
|
+
return jupyter.request(action, method);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
await auth.ready;
|
|
249
|
+
|
|
250
|
+
failing = true;
|
|
251
|
+
await auth.refresh();
|
|
252
|
+
|
|
253
|
+
// One failed request is not a sign-out.
|
|
254
|
+
expect(auth.user?.email).toBe('ada@example.org');
|
|
255
|
+
auth.dispose();
|
|
256
|
+
warn.mockRestore();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('cancels a sign-in that is waiting', async () => {
|
|
260
|
+
const jupyter = server(SIGNED_OUT);
|
|
261
|
+
const auth = new ServerAuth({
|
|
262
|
+
environment: ENVIRONMENT,
|
|
263
|
+
request: jupyter.request,
|
|
264
|
+
pendingPollMs: 10000
|
|
265
|
+
});
|
|
266
|
+
await auth.ready;
|
|
267
|
+
jupyter.state.status = WAITING;
|
|
268
|
+
await auth.signIn();
|
|
269
|
+
|
|
270
|
+
jupyter.state.status = SIGNED_OUT;
|
|
271
|
+
await auth.cancelSignIn();
|
|
272
|
+
|
|
273
|
+
expect(jupyter.calls).toContain('POST cancel');
|
|
274
|
+
expect(auth.pending).toBeNull();
|
|
275
|
+
auth.dispose();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
|
+
|
|
6
|
+
const ID = '@oceanum/oceanumlab:datamesh-connect';
|
|
7
|
+
const schema = JSON.parse(
|
|
8
|
+
readFileSync(join(__dirname, '../../schema/datamesh-connect.json'), 'utf8')
|
|
9
|
+
) as ISettingRegistry.ISchema;
|
|
10
|
+
|
|
11
|
+
/** A registry whose stored user settings for the plugin are `user`. */
|
|
12
|
+
function registryWith(user: Record<string, unknown>): SettingRegistry {
|
|
13
|
+
const plugin: ISettingRegistry.IPlugin = {
|
|
14
|
+
id: ID,
|
|
15
|
+
data: { user: {}, composite: {} },
|
|
16
|
+
raw: JSON.stringify(user),
|
|
17
|
+
schema,
|
|
18
|
+
version: 'test'
|
|
19
|
+
};
|
|
20
|
+
const connector = {
|
|
21
|
+
fetch: async (): Promise<ISettingRegistry.IPlugin> => plugin,
|
|
22
|
+
list: async () => ({ ids: [ID], values: [plugin] }),
|
|
23
|
+
save: async (): Promise<void> => undefined,
|
|
24
|
+
remove: async (): Promise<void> => undefined
|
|
25
|
+
};
|
|
26
|
+
return new SettingRegistry({ connector: connector as any });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `datameshUiUrl` and `aiBackendUrl` were user settings until 4.7.1. They are deployment
|
|
31
|
+
* configuration now: a user-editable service address is somewhere to send the user's
|
|
32
|
+
* credential.
|
|
33
|
+
*
|
|
34
|
+
* Taking them out of the schema is not enough on its own, and would have been a breaking
|
|
35
|
+
* release. The schema is `additionalProperties: false`, the registry REJECTS a load whose
|
|
36
|
+
* stored settings fail validation, and these two keys are stored for every
|
|
37
|
+
* notebook.oceanum.io user: until this release that site wrote each environment's addresses
|
|
38
|
+
* into oceanumlab's settings. Every one of them would have lost the whole settings load --
|
|
39
|
+
* the Datamesh token with it.
|
|
40
|
+
*/
|
|
41
|
+
describe('the datamesh-connect settings schema', () => {
|
|
42
|
+
it('no longer offers the two service addresses as settings', () => {
|
|
43
|
+
expect(Object.keys(schema.properties ?? {})).toEqual([
|
|
44
|
+
'datameshToken',
|
|
45
|
+
'injectToken',
|
|
46
|
+
'autoRunCode',
|
|
47
|
+
'iterate'
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('still loads settings stored when they were', async () => {
|
|
52
|
+
const registry = registryWith({
|
|
53
|
+
datameshToken: 'a-token',
|
|
54
|
+
datameshUiUrl: 'https://ui.datamesh.oceanum.tech',
|
|
55
|
+
aiBackendUrl: 'https://ai.oceanum.tech'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const settings = await registry.load(ID);
|
|
59
|
+
|
|
60
|
+
expect(settings.get('datameshToken').composite).toBe('a-token');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stays strict about everything else', async () => {
|
|
64
|
+
// Tolerating those two must not turn into tolerating typos.
|
|
65
|
+
const warn = jest
|
|
66
|
+
.spyOn(console, 'warn')
|
|
67
|
+
.mockImplementation(() => undefined);
|
|
68
|
+
const registry = registryWith({ datameshTokn: 'a-typo' });
|
|
69
|
+
|
|
70
|
+
await expect(registry.load(ID)).rejects.toBeDefined();
|
|
71
|
+
warn.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ISpecSummary, listNotebooks, partitionNotebooks } from '../specStore';
|
|
2
|
+
|
|
3
|
+
function summary(overrides: Partial<ISpecSummary> = {}): ISpecSummary {
|
|
4
|
+
return {
|
|
5
|
+
id: 'nb-1',
|
|
6
|
+
name: 'Wave analysis',
|
|
7
|
+
description: null,
|
|
8
|
+
modified: '2026-05-17T12:00:00',
|
|
9
|
+
creator: null,
|
|
10
|
+
...overrides
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function response(body: unknown, status = 200): Response {
|
|
15
|
+
return {
|
|
16
|
+
ok: status >= 200 && status < 300,
|
|
17
|
+
status,
|
|
18
|
+
json: async () => body
|
|
19
|
+
} as unknown as Response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('listNotebooks', () => {
|
|
23
|
+
it('asks the notebook spec type with the token as a bearer credential', async () => {
|
|
24
|
+
const calls: [string, RequestInit | undefined][] = [];
|
|
25
|
+
const fetcher = (async (url: string, init?: RequestInit) => {
|
|
26
|
+
calls.push([url, init]);
|
|
27
|
+
return response([]);
|
|
28
|
+
}) as unknown as typeof fetch;
|
|
29
|
+
|
|
30
|
+
await listNotebooks('https://specs.example.com', 'jwt', fetcher);
|
|
31
|
+
|
|
32
|
+
expect(calls[0][0]).toBe('https://specs.example.com/specs/notebook');
|
|
33
|
+
expect((calls[0][1]?.headers as Record<string, string>).Authorization).toBe(
|
|
34
|
+
'Bearer jwt'
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('tolerates a trailing slash on the service url', async () => {
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
const fetcher = (async (url: string) => {
|
|
41
|
+
calls.push(url);
|
|
42
|
+
return response([]);
|
|
43
|
+
}) as unknown as typeof fetch;
|
|
44
|
+
|
|
45
|
+
await listNotebooks('https://specs.example.com//', 'jwt', fetcher);
|
|
46
|
+
|
|
47
|
+
expect(calls[0]).toBe('https://specs.example.com/specs/notebook');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('reports the status rather than returning an empty list', async () => {
|
|
51
|
+
// The store answers a bad or expired token with 400, so silently returning []
|
|
52
|
+
// would look exactly like an account with no notebooks.
|
|
53
|
+
const fetcher = (async () =>
|
|
54
|
+
response({ detail: 'bad' }, 400)) as unknown as typeof fetch;
|
|
55
|
+
await expect(
|
|
56
|
+
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
57
|
+
).rejects.toThrow('HTTP 400');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a body that is not a list', async () => {
|
|
61
|
+
const fetcher = (async () =>
|
|
62
|
+
response({ detail: 'nope' })) as unknown as typeof fetch;
|
|
63
|
+
await expect(
|
|
64
|
+
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
65
|
+
).rejects.toThrow('unexpected response');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('drops entries that are not summaries rather than rendering undefined', async () => {
|
|
69
|
+
const fetcher = (async () =>
|
|
70
|
+
response([
|
|
71
|
+
summary({ id: 'good' }),
|
|
72
|
+
{ id: 'bad' },
|
|
73
|
+
null
|
|
74
|
+
])) as unknown as typeof fetch;
|
|
75
|
+
const items = await listNotebooks(
|
|
76
|
+
'https://specs.example.com',
|
|
77
|
+
'jwt',
|
|
78
|
+
fetcher
|
|
79
|
+
);
|
|
80
|
+
expect(items.map(item => item.id)).toEqual(['good']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('partitionNotebooks', () => {
|
|
85
|
+
it('splits the caller own records from the rest', () => {
|
|
86
|
+
const mine = summary({ id: 'mine', creator: 'dave@oceanum.science' });
|
|
87
|
+
const theirs = summary({ id: 'theirs', creator: null });
|
|
88
|
+
const { mine: own, shared } = partitionNotebooks(
|
|
89
|
+
[mine, theirs],
|
|
90
|
+
'dave@oceanum.science'
|
|
91
|
+
);
|
|
92
|
+
expect(own.map(i => i.id)).toEqual(['mine']);
|
|
93
|
+
expect(shared.map(i => i.id)).toEqual(['theirs']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('compares addresses case-insensitively', () => {
|
|
97
|
+
// The store matches entities as case-sensitive globs, so the claim and the stored
|
|
98
|
+
// creator can differ in case for the same person.
|
|
99
|
+
const { mine } = partitionNotebooks(
|
|
100
|
+
[summary({ creator: 'Dave@Oceanum.Science' })],
|
|
101
|
+
'dave@oceanum.science'
|
|
102
|
+
);
|
|
103
|
+
expect(mine).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('treats everything as shared when nobody is signed in', () => {
|
|
107
|
+
const { mine, shared } = partitionNotebooks(
|
|
108
|
+
[summary({ creator: null }), summary({ id: 'b', creator: 'a@b.com' })],
|
|
109
|
+
null
|
|
110
|
+
);
|
|
111
|
+
expect(mine).toEqual([]);
|
|
112
|
+
expect(shared).toHaveLength(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('orders each group newest first without mutating the input', () => {
|
|
116
|
+
const input = [
|
|
117
|
+
summary({ id: 'old', modified: '2026-01-01T00:00:00' }),
|
|
118
|
+
summary({ id: 'new', modified: '2026-05-17T00:00:00' })
|
|
119
|
+
];
|
|
120
|
+
expect(partitionNotebooks(input, null).shared.map(i => i.id)).toEqual([
|
|
121
|
+
'new',
|
|
122
|
+
'old'
|
|
123
|
+
]);
|
|
124
|
+
expect(input.map(i => i.id)).toEqual(['old', 'new']);
|
|
125
|
+
});
|
|
126
|
+
});
|
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 a configured 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
|
+
}
|