@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.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +18 -2
- package/lib/DatameshWidget.js +80 -12
- package/lib/StoredNotebooks.d.ts +20 -0
- package/lib/StoredNotebooks.js +112 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +1 -1
- package/lib/aiBackend.js +1 -1
- 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 +254 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +102 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +13 -1
- package/lib/chatRouter.js +9 -2
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +6 -6
- package/lib/index.d.ts +8 -1
- package/lib/index.js +58 -17
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +13 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +143 -19
- package/src/StoredNotebooks.tsx +206 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +157 -0
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +5 -6
- package/src/__tests__/chatRouter.spec.ts +9 -15
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- 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__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/aiBackend.ts +1 -1
- 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 +342 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +111 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +87 -36
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +266 -13
package/src/index.ts
CHANGED
|
@@ -18,6 +18,10 @@ import { requestAPI } from './handler';
|
|
|
18
18
|
import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
|
|
19
19
|
import { reporterFor } from './progress';
|
|
20
20
|
import { ConversationPin } from './conversationPin';
|
|
21
|
+
import { deviceAuthPlugin } from './auth/plugin';
|
|
22
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
23
|
+
import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
|
|
24
|
+
|
|
21
25
|
import { snapshotFromIpynb, snapshotOf } from './notebookContext';
|
|
22
26
|
import { notebookHost } from './notebookHost';
|
|
23
27
|
import { KernelHandoff } from './kernelHandoff';
|
|
@@ -53,20 +57,27 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
53
57
|
ISettingRegistry,
|
|
54
58
|
IStateDB
|
|
55
59
|
],
|
|
60
|
+
// Optional so the panel still loads on a host with no Oceanum environment; the
|
|
61
|
+
// Notebooks tab then explains why it is empty instead of the panel failing to start.
|
|
62
|
+
optional: [IOceanumAuth],
|
|
56
63
|
activate: (
|
|
57
64
|
app: JupyterFrontEnd,
|
|
58
65
|
palette: ICommandPalette,
|
|
59
66
|
restorer: ILayoutRestorer,
|
|
60
67
|
status: ILabStatus,
|
|
61
|
-
settingRegistry: ISettingRegistry
|
|
68
|
+
settingRegistry: ISettingRegistry,
|
|
69
|
+
stateDB: IStateDB,
|
|
70
|
+
auth: IOceanumAuth | null
|
|
62
71
|
) => {
|
|
63
72
|
console.log('Oceanum datamesh connect extension is loaded');
|
|
64
73
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
74
|
+
// Where the Datamesh UI and Oceanum AI live is the deployment's business, so it comes
|
|
75
|
+
// from the sign-in environment (the Jupyter server's configuration, or the notebook
|
|
76
|
+
// site's) and falls back to production. These were user settings until 4.7.1; a
|
|
77
|
+
// user-editable service address is somewhere to send the user's credential.
|
|
78
|
+
const datameshUiUrl = auth?.urls?.datameshUi ?? DATAMESH_UI_SERVICE;
|
|
79
|
+
const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
|
|
80
|
+
// The `datameshToken` setting for the AI chat; empty until the settings load.
|
|
70
81
|
let datameshTokenSetting = '';
|
|
71
82
|
// Tells the sidebar the settings above have changed.
|
|
72
83
|
const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
|
|
@@ -88,14 +99,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
88
99
|
}).then(res => console.log(res));
|
|
89
100
|
}
|
|
90
101
|
window.injectToken = set.get('injectToken').user as boolean;
|
|
91
|
-
aiBackendUrl = set.get('aiBackendUrl').composite as string;
|
|
92
102
|
datameshTokenSetting = set.get('datameshToken').composite as string;
|
|
93
|
-
// An open Datamesh UI panel follows a change of address.
|
|
94
|
-
datameshUiUrl = set.get('datameshUiUrl').composite as string;
|
|
95
|
-
const datameshUI = findDatameshUI();
|
|
96
|
-
if (datameshUI) {
|
|
97
|
-
datameshUI.url = datameshUiUrl;
|
|
98
|
-
}
|
|
99
103
|
settingsChanged.emit();
|
|
100
104
|
};
|
|
101
105
|
//Try to get the datamesh token from the envars
|
|
@@ -145,7 +149,13 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
145
149
|
aiBackendUrl: () => aiBackendUrl,
|
|
146
150
|
settingsChanged,
|
|
147
151
|
commands: app.commands,
|
|
148
|
-
getCurrentWidget
|
|
152
|
+
getCurrentWidget,
|
|
153
|
+
auth,
|
|
154
|
+
openStoredNotebook: id => {
|
|
155
|
+
app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
|
|
156
|
+
console.error('Oceanum: could not open the stored notebook.', error);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
149
159
|
});
|
|
150
160
|
datameshConnectWidget.id = 'datamesh-connect';
|
|
151
161
|
datameshConnectWidget.title.icon = oceanumIcon;
|
|
@@ -153,8 +163,30 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
153
163
|
|
|
154
164
|
restorer.add(datameshConnectWidget, 'datamesh-connect');
|
|
155
165
|
|
|
166
|
+
// The layout restorer restores the widget's place in the shell, not fields on it,
|
|
167
|
+
// so the chosen tab is kept here. Restoring is best effort: a missing or unreadable
|
|
168
|
+
// entry just leaves the default.
|
|
169
|
+
const TAB_STATE_KEY = `${PLUGIN_ID}:tab`;
|
|
170
|
+
void stateDB
|
|
171
|
+
.fetch(TAB_STATE_KEY)
|
|
172
|
+
.then(value => {
|
|
173
|
+
if (typeof value === 'string') {
|
|
174
|
+
datameshConnectWidget.selectTab(value);
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.catch((): void => undefined);
|
|
178
|
+
datameshConnectWidget.tabChanged.connect((_, id) => {
|
|
179
|
+
void stateDB.save(TAB_STATE_KEY, id);
|
|
180
|
+
});
|
|
181
|
+
|
|
156
182
|
// Rank has been chosen somewhat arbitrarily to give priority to the running
|
|
157
183
|
// sessions widget in the sidebar.
|
|
184
|
+
//
|
|
185
|
+
// Oceanum Notebook wants this panel first instead, but that is a property of that
|
|
186
|
+
// distribution rather than of the extension: in a plain JupyterLab the file browser
|
|
187
|
+
// is the primary navigation surface and displacing it would be a regression. A
|
|
188
|
+
// distribution moves it with JupyterLab's own shell user-layout settings, keyed on
|
|
189
|
+
// this widget's id, rather than this extension hard-coding one host's preference.
|
|
158
190
|
app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
|
|
159
191
|
|
|
160
192
|
app.commands.addCommand('datamesh-ui:open', {
|
|
@@ -174,10 +206,12 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
|
|
|
174
206
|
id: '@oceanum/oceanumlab:ai-chat',
|
|
175
207
|
autoStart: true,
|
|
176
208
|
requires: [INotebookTracker, ISettingRegistry],
|
|
209
|
+
optional: [IOceanumAuth],
|
|
177
210
|
activate: (
|
|
178
211
|
app: JupyterFrontEnd,
|
|
179
212
|
notebookTracker: INotebookTracker,
|
|
180
|
-
settingRegistry: ISettingRegistry
|
|
213
|
+
settingRegistry: ISettingRegistry,
|
|
214
|
+
auth: IOceanumAuth | null
|
|
181
215
|
) => {
|
|
182
216
|
console.log('Oceanum AI chat extension is loaded');
|
|
183
217
|
|
|
@@ -197,26 +231,31 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
|
|
|
197
231
|
// The panel the current request read its selected cell from, if the
|
|
198
232
|
// notebook was open: a code answer may replace that cell only there.
|
|
199
233
|
let readFrom: NotebookPanel | null = null;
|
|
200
|
-
const router = new ChatRouter(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
const router = new ChatRouter(
|
|
235
|
+
settings,
|
|
236
|
+
app.commands,
|
|
237
|
+
async () => {
|
|
238
|
+
const open = await pin.current();
|
|
239
|
+
readFrom = open;
|
|
240
|
+
if (open) {
|
|
241
|
+
return snapshotOf(open.content);
|
|
242
|
+
}
|
|
243
|
+
const path = pin.path();
|
|
244
|
+
if (!path) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const file = await app.serviceManager.contents.get(path, {
|
|
249
|
+
content: true
|
|
250
|
+
});
|
|
251
|
+
return snapshotFromIpynb(file.content);
|
|
252
|
+
} catch {
|
|
253
|
+
// Gone: an answer's cells will go into a new notebook instead.
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
() => auth?.urls?.ai ?? ''
|
|
258
|
+
);
|
|
220
259
|
// Asked only when an answer has blocks to place: brings the
|
|
221
260
|
// conversation's notebook to the front, opening it again if closed.
|
|
222
261
|
const handoff = new KernelHandoff(
|
|
@@ -352,4 +391,16 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
|
|
|
352
391
|
}
|
|
353
392
|
};
|
|
354
393
|
|
|
355
|
-
|
|
394
|
+
/**
|
|
395
|
+
* The sign-in contract, exported so another extension can provide or consume it: a Lumino
|
|
396
|
+
* token is matched by object identity, so everyone has to import this one object, and
|
|
397
|
+
* JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
|
|
398
|
+
*/
|
|
399
|
+
export * from './auth/tokens';
|
|
400
|
+
|
|
401
|
+
export default [
|
|
402
|
+
deviceAuthPlugin,
|
|
403
|
+
sharePlugin,
|
|
404
|
+
datamesh_connect_extension,
|
|
405
|
+
oceanum_ai_extension
|
|
406
|
+
];
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSpecId,
|
|
3
|
+
ISpecBody,
|
|
4
|
+
ISpecPatch,
|
|
5
|
+
ISpecRecord,
|
|
6
|
+
ISpecSummary
|
|
7
|
+
} from './notebook';
|
|
8
|
+
|
|
9
|
+
export type ErrorKind =
|
|
10
|
+
| 'signed-out'
|
|
11
|
+
| 'expired'
|
|
12
|
+
| 'forbidden'
|
|
13
|
+
| 'not-found'
|
|
14
|
+
| 'too-large'
|
|
15
|
+
| 'network'
|
|
16
|
+
| 'invalid'
|
|
17
|
+
| 'unsupported'
|
|
18
|
+
| 'server';
|
|
19
|
+
|
|
20
|
+
const MESSAGES: Record<Exclude<ErrorKind, 'server'>, string> = {
|
|
21
|
+
'signed-out': 'Sign in to Oceanum.io to continue.',
|
|
22
|
+
expired: 'Your Oceanum session has expired, sign in again.',
|
|
23
|
+
forbidden:
|
|
24
|
+
'You have no access to this notebook. It may not exist, or was created under another organisation.',
|
|
25
|
+
'not-found': 'This notebook no longer exists on Oceanum.io.',
|
|
26
|
+
'too-large': 'The notebook is too large for Oceanum.io (32 MiB limit).',
|
|
27
|
+
network:
|
|
28
|
+
'Could not reach Oceanum.io. Check your connection; very large notebooks can also fail this way.',
|
|
29
|
+
invalid: 'Oceanum.io returned an unexpected response.',
|
|
30
|
+
unsupported: 'This Oceanum.io service does not offer that operation yet.'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A spec store failure with a message that is safe to show to the user. */
|
|
34
|
+
export class SpecStoreError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly kind: ErrorKind,
|
|
37
|
+
readonly status: number | null = null
|
|
38
|
+
) {
|
|
39
|
+
super(
|
|
40
|
+
kind === 'server'
|
|
41
|
+
? `Oceanum.io returned an error (HTTP ${status}).`
|
|
42
|
+
: MESSAGES[kind]
|
|
43
|
+
);
|
|
44
|
+
this.name = 'SpecStoreError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map a failed HTTP status to an error. */
|
|
49
|
+
export function errorForStatus(status: number): SpecStoreError {
|
|
50
|
+
switch (status) {
|
|
51
|
+
// The spec store's authentication middleware answers a bad or expired token with 400.
|
|
52
|
+
case 400:
|
|
53
|
+
return new SpecStoreError('expired', status);
|
|
54
|
+
// Casbin answers 403 both for no access and for a record that does not exist.
|
|
55
|
+
case 403:
|
|
56
|
+
return new SpecStoreError('forbidden', status);
|
|
57
|
+
case 404:
|
|
58
|
+
return new SpecStoreError('not-found', status);
|
|
59
|
+
case 405:
|
|
60
|
+
// The route is there but not this method: a store from before it had PATCH.
|
|
61
|
+
return new SpecStoreError('unsupported', status);
|
|
62
|
+
case 413:
|
|
63
|
+
return new SpecStoreError('too-large', status);
|
|
64
|
+
default:
|
|
65
|
+
return new SpecStoreError('server', status);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SharePermission = 'read' | 'write';
|
|
70
|
+
|
|
71
|
+
/** A permission grant, as accepted by `POST /specs/notebook/{id}/permissions`. */
|
|
72
|
+
export type PermissionGrant =
|
|
73
|
+
| { type: 'user'; entity: string; permission: SharePermission }
|
|
74
|
+
| { type: 'public'; entity: ''; permission: 'read' };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the share dialog asks for: add grants, or remove one (e.g. stop public access).
|
|
78
|
+
*
|
|
79
|
+
* Granting carries a list because the dialog shares with several people at once, while
|
|
80
|
+
* the spec store takes one grant per request. `invalid` carries back anything typed that
|
|
81
|
+
* is not a shareable address, so the caller can name it rather than failing with
|
|
82
|
+
* "invalid email".
|
|
83
|
+
*/
|
|
84
|
+
export type ShareChoice =
|
|
85
|
+
| { action: 'grant'; grants: PermissionGrant[]; invalid: string[] }
|
|
86
|
+
| { action: 'revoke'; grant: PermissionGrant };
|
|
87
|
+
|
|
88
|
+
export interface ISpecStoreClientOptions {
|
|
89
|
+
/** Spec store base URL, e.g. `https://specs.oceanum.io`. */
|
|
90
|
+
specsUrl: string;
|
|
91
|
+
/** Returns a current access token or `null`. Called for every request. */
|
|
92
|
+
getAccessToken: () => Promise<string | null>;
|
|
93
|
+
fetch?: typeof fetch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isSummary(value: unknown): value is ISpecSummary {
|
|
97
|
+
if (typeof value !== 'object' || value === null) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const v = value as Record<string, unknown>;
|
|
101
|
+
return (
|
|
102
|
+
isSpecId(v.id) &&
|
|
103
|
+
typeof v.name === 'string' &&
|
|
104
|
+
typeof v.modified === 'string'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isRecord(value: unknown): value is ISpecRecord {
|
|
109
|
+
return isSummary(value) && 'spec' in value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Client for the `notebook` spec type of the Oceanum spec store. */
|
|
113
|
+
export class SpecStoreClient {
|
|
114
|
+
constructor(options: ISpecStoreClientOptions) {
|
|
115
|
+
this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/notebook`;
|
|
116
|
+
this._getAccessToken = options.getAccessToken;
|
|
117
|
+
this._fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every notebook the user may read, including public ones. */
|
|
121
|
+
async list(): Promise<ISpecSummary[]> {
|
|
122
|
+
const data = await this._request('GET', this._base, { auth: 'required' });
|
|
123
|
+
if (!Array.isArray(data) || !data.every(isSummary)) {
|
|
124
|
+
throw new SpecStoreError('invalid');
|
|
125
|
+
}
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read a record, anonymously when signed out. */
|
|
130
|
+
async get(id: string): Promise<ISpecRecord> {
|
|
131
|
+
return this._record(
|
|
132
|
+
await this._request('GET', this._url(id), { auth: 'optional' })
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Create a record. The server mints the id: use the returned record's. */
|
|
137
|
+
async create(body: ISpecBody): Promise<ISpecRecord> {
|
|
138
|
+
return this._record(
|
|
139
|
+
await this._request('POST', this._base, {
|
|
140
|
+
auth: 'required',
|
|
141
|
+
body: pick(body)
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Replace a record's name, description and notebook. */
|
|
147
|
+
async update(id: string, body: ISpecBody): Promise<ISpecRecord> {
|
|
148
|
+
return this._record(
|
|
149
|
+
await this._request('PUT', this._url(id), {
|
|
150
|
+
auth: 'required',
|
|
151
|
+
body: pick(body)
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Change some of a record's fields and leave the rest as they are. Needs the same
|
|
158
|
+
* write access as `update`, which the store enforces a patch as.
|
|
159
|
+
*/
|
|
160
|
+
async patch(id: string, changes: ISpecPatch): Promise<ISpecRecord> {
|
|
161
|
+
if (Object.keys(changes).length === 0) {
|
|
162
|
+
// The store answers an empty patch with 400; there is nothing to send anyway.
|
|
163
|
+
throw new SpecStoreError('invalid');
|
|
164
|
+
}
|
|
165
|
+
return this._record(
|
|
166
|
+
await this._request('PATCH', this._url(id), {
|
|
167
|
+
auth: 'required',
|
|
168
|
+
body: changes
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Give a record a new name, without sending the notebook back with it.
|
|
175
|
+
*
|
|
176
|
+
* Falls back to reading the record and putting it back where the store has no PATCH
|
|
177
|
+
* (production had none when this was written). That fallback races another writer's
|
|
178
|
+
* change to the notebook, which is the whole reason to prefer a patch.
|
|
179
|
+
*/
|
|
180
|
+
async rename(id: string, name: string): Promise<ISpecRecord> {
|
|
181
|
+
try {
|
|
182
|
+
return await this.patch(id, { name });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!(error instanceof SpecStoreError) || error.kind !== 'unsupported') {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const record = await this.get(id);
|
|
188
|
+
return this.update(id, {
|
|
189
|
+
name,
|
|
190
|
+
description: record.description,
|
|
191
|
+
spec: record.spec as ISpecBody['spec']
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Delete a record (requires admin access to it). */
|
|
197
|
+
async remove(id: string): Promise<void> {
|
|
198
|
+
await this._request('DELETE', this._url(id), { auth: 'required' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Grant a permission on a record (requires admin access to it). */
|
|
202
|
+
async addPermission(id: string, grant: PermissionGrant): Promise<void> {
|
|
203
|
+
await this._request('POST', `${this._url(id)}/permissions`, {
|
|
204
|
+
auth: 'required',
|
|
205
|
+
body: {
|
|
206
|
+
type: grant.type,
|
|
207
|
+
entity: grant.entity,
|
|
208
|
+
permission: grant.permission
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove a permission (requires admin access). The server matches the exact grant and
|
|
215
|
+
* answers 404 when there is no such permission.
|
|
216
|
+
*/
|
|
217
|
+
async removePermission(id: string, grant: PermissionGrant): Promise<void> {
|
|
218
|
+
await this._request('DELETE', `${this._url(id)}/permissions`, {
|
|
219
|
+
auth: 'required',
|
|
220
|
+
body: {
|
|
221
|
+
type: grant.type,
|
|
222
|
+
entity: grant.entity,
|
|
223
|
+
permission: grant.permission
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private _url(id: string): string {
|
|
229
|
+
if (!isSpecId(id)) {
|
|
230
|
+
throw new SpecStoreError('invalid');
|
|
231
|
+
}
|
|
232
|
+
return `${this._base}/${id}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private _record(data: unknown): ISpecRecord {
|
|
236
|
+
if (!isRecord(data)) {
|
|
237
|
+
throw new SpecStoreError('invalid');
|
|
238
|
+
}
|
|
239
|
+
return data;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _request(
|
|
243
|
+
method: string,
|
|
244
|
+
url: string,
|
|
245
|
+
options: { auth: 'required' | 'optional'; body?: object }
|
|
246
|
+
): Promise<unknown> {
|
|
247
|
+
const token = await this._getAccessToken();
|
|
248
|
+
if (token === null && options.auth === 'required') {
|
|
249
|
+
throw new SpecStoreError('signed-out');
|
|
250
|
+
}
|
|
251
|
+
const headers: Record<string, string> = {};
|
|
252
|
+
if (token !== null) {
|
|
253
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
254
|
+
}
|
|
255
|
+
const init: RequestInit = { method, headers };
|
|
256
|
+
if (options.body !== undefined) {
|
|
257
|
+
headers['Content-Type'] = 'application/json';
|
|
258
|
+
init.body = JSON.stringify(options.body);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let response: Response;
|
|
262
|
+
try {
|
|
263
|
+
response = await this._fetch(url, init);
|
|
264
|
+
} catch {
|
|
265
|
+
throw new SpecStoreError('network');
|
|
266
|
+
}
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
throw errorForStatus(response.status);
|
|
269
|
+
}
|
|
270
|
+
if (response.status === 204) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
return await response.json();
|
|
275
|
+
} catch {
|
|
276
|
+
throw new SpecStoreError('invalid');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private readonly _base: string;
|
|
281
|
+
private readonly _getAccessToken: () => Promise<string | null>;
|
|
282
|
+
private readonly _fetch: typeof fetch;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Only the fields the server accepts; in particular never send an `id`. */
|
|
286
|
+
function pick(body: ISpecBody): ISpecBody {
|
|
287
|
+
return { name: body.name, description: body.description, spec: body.spec };
|
|
288
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Signal } from '@lumino/signaling';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emits after this extension creates, renames or deletes a notebook record, so the
|
|
5
|
+
* Notebooks tab can list again. A plain module-level signal: the tab and the commands
|
|
6
|
+
* live in different plugins, and the change is the only thing they need to share.
|
|
7
|
+
*/
|
|
8
|
+
// The sender is a throwaway object, not null: Lumino keys its receivers by sender in a
|
|
9
|
+
// WeakMap, which refuses null.
|
|
10
|
+
export const notebooksChanged = new Signal<object, void>({});
|