@oceanum/oceanumlab 4.7.1 → 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/lib/DatameshWidget.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- 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 +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -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/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- 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 +111 -16
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- package/src/specStore.ts +0 -82
package/lib/DatameshWidget.js
CHANGED
|
@@ -9,9 +9,10 @@ import { Signal } from '@lumino/signaling';
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { marked } from 'marked';
|
|
11
11
|
import { DatasourceItem } from './DatasourceItem';
|
|
12
|
+
import { startSignIn } from './auth/startSignIn';
|
|
12
13
|
import { StoredNotebooks } from './StoredNotebooks';
|
|
13
14
|
import { Tabs } from './Tabs';
|
|
14
|
-
import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
|
|
15
|
+
import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, isSignedIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
|
|
15
16
|
import { isDatameshUiMessage } from './datameshUiUrl';
|
|
16
17
|
import { describeProgress, onProgress } from './progress';
|
|
17
18
|
// Configure marked for safe rendering
|
|
@@ -292,6 +293,7 @@ function readAiAccess(commands, settings) {
|
|
|
292
293
|
source: aiCredentialSource(datameshToken, commands),
|
|
293
294
|
pasted: pastedToken(datameshToken),
|
|
294
295
|
canSignIn: canSignIn(commands),
|
|
296
|
+
signedIn: isSignedIn(commands),
|
|
295
297
|
backend: aiBackendUrl(settings.aiBackendUrl())
|
|
296
298
|
};
|
|
297
299
|
}
|
|
@@ -609,7 +611,7 @@ function AIChatPanel({ commands, settings }) {
|
|
|
609
611
|
* credential to work with — otherwise it would open on the same nothing the panel
|
|
610
612
|
* rendered before there were tabs, except now with a label promising otherwise.
|
|
611
613
|
*/
|
|
612
|
-
function PanelTabs({ auth, commands, settings, renderDatamesh, selected, onSelect }) {
|
|
614
|
+
function PanelTabs({ auth, commands, settings, renderDatamesh, openStoredNotebook, selected, onSelect }) {
|
|
613
615
|
const access = useAiAccess(commands, settings);
|
|
614
616
|
const tabs = [
|
|
615
617
|
{
|
|
@@ -618,11 +620,18 @@ function PanelTabs({ auth, commands, settings, renderDatamesh, selected, onSelec
|
|
|
618
620
|
render: () =>
|
|
619
621
|
// An auth with no environment is a provider that has nothing to sign in to (the
|
|
620
622
|
// server has sign-in turned off). Asking the user to sign in would be a dead end.
|
|
621
|
-
(auth === null || auth === void 0 ? void 0 : auth.environment) ? (React.createElement(StoredNotebooks, { auth: auth
|
|
623
|
+
(auth === null || auth === void 0 ? void 0 : auth.environment) ? (React.createElement(StoredNotebooks, { auth: auth, onOpen: openStoredNotebook && (item => openStoredNotebook(item.id)), onSignIn: () => {
|
|
624
|
+
startSignIn(commands, auth).catch(error => {
|
|
625
|
+
console.warn('Oceanum.io sign-in could not start.', error);
|
|
626
|
+
});
|
|
627
|
+
} })) : (React.createElement("div", { className: "oceanum-text-empty" }, "Oceanum.io sign-in is not configured for this host."))
|
|
622
628
|
},
|
|
623
629
|
{ id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
|
|
624
630
|
];
|
|
625
|
-
|
|
631
|
+
// Signed in, not merely holding a credential: a Datamesh token pasted into the
|
|
632
|
+
// settings would reach the backend, but the tab stays out of the way until someone
|
|
633
|
+
// has signed in to Oceanum.io.
|
|
634
|
+
if (access.signedIn) {
|
|
626
635
|
tabs.push({
|
|
627
636
|
id: 'ai',
|
|
628
637
|
label: 'Oceanum AI',
|
|
@@ -684,7 +693,7 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
684
693
|
React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
|
|
685
694
|
React.createElement(UseSignal, { signal: this.tabChanged, initialArgs: this.selectedTab }, () => {
|
|
686
695
|
var _a;
|
|
687
|
-
return (React.createElement(PanelTabs, { auth: (_a = this.props.auth) !== null && _a !== void 0 ? _a : null, commands: this.props.commands, settings: this.props, renderDatamesh: () => this.renderDatamesh(), selected: this.selectedTab, onSelect: id => this.selectTab(id) }));
|
|
696
|
+
return (React.createElement(PanelTabs, { auth: (_a = this.props.auth) !== null && _a !== void 0 ? _a : null, commands: this.props.commands, settings: this.props, renderDatamesh: () => this.renderDatamesh(), openStoredNotebook: this.props.openStoredNotebook, selected: this.selectedTab, onSelect: id => this.selectTab(id) }));
|
|
688
697
|
})));
|
|
689
698
|
}
|
|
690
699
|
/**
|
package/lib/StoredNotebooks.d.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { IOceanumAuth } from './auth/tokens';
|
|
3
|
-
import { ISpecSummary } from './
|
|
3
|
+
import { ISpecSummary } from './share/notebook';
|
|
4
4
|
export interface IStoredNotebooksProps {
|
|
5
5
|
auth: IOceanumAuth;
|
|
6
6
|
/**
|
|
7
|
-
* Open a stored notebook
|
|
8
|
-
*
|
|
9
|
-
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
7
|
+
* Open a stored notebook. Undefined where nothing has registered a way to open one;
|
|
8
|
+
* the list then reads rather than offering a click that does nothing.
|
|
10
9
|
*/
|
|
11
10
|
onOpen?: (item: ISpecSummary) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Start signing in the way this host shows it; without it the auth's own `signIn()`
|
|
13
|
+
* is used, which on a JupyterLab server shows no code dialog.
|
|
14
|
+
*/
|
|
15
|
+
onSignIn?: () => void;
|
|
12
16
|
}
|
|
13
17
|
/**
|
|
14
18
|
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
15
19
|
*/
|
|
16
|
-
export declare function StoredNotebooks({ auth, onOpen }: IStoredNotebooksProps): React.ReactElement;
|
|
20
|
+
export declare function StoredNotebooks({ auth, onOpen, onSignIn }: IStoredNotebooksProps): React.ReactElement;
|
package/lib/StoredNotebooks.js
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { SpecStoreClient } from './share/client';
|
|
3
|
+
import { notebooksChanged } from './share/events';
|
|
4
|
+
import { partitionSummaries } from './share/notebook';
|
|
5
|
+
import { SPEC_ID_ATTRIBUTE } from './share/plugin';
|
|
3
6
|
function Section({ title, items, empty, onOpen }) {
|
|
7
|
+
// Each row carries its record id so the share plugin's context menu (Open, Rename,
|
|
8
|
+
// Share, Delete) can tell which one was clicked.
|
|
9
|
+
const rowAttributes = (item) => {
|
|
10
|
+
var _a;
|
|
11
|
+
return ({
|
|
12
|
+
[SPEC_ID_ATTRIBUTE]: item.id,
|
|
13
|
+
title: (_a = item.description) !== null && _a !== void 0 ? _a : undefined
|
|
14
|
+
});
|
|
15
|
+
};
|
|
4
16
|
return (React.createElement("div", { className: "oceanum-notebooks-section" },
|
|
5
17
|
React.createElement("div", { className: "oceanum-notebooks-heading" },
|
|
6
18
|
title,
|
|
7
19
|
React.createElement("span", { className: "oceanum-notebooks-count" }, items.length)),
|
|
8
|
-
items.length === 0 ? (React.createElement("div", { className: "oceanum-text-empty" }, empty)) : (React.createElement("ul", { className: "oceanum-notebooks-list" }, items.map(item => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))) : (React.createElement("div", { className: "oceanum-notebooks-item oceanum-notebooks-item-static", title: (_b = item.description) !== null && _b !== void 0 ? _b : undefined },
|
|
12
|
-
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled')))));
|
|
13
|
-
})))));
|
|
20
|
+
items.length === 0 ? (React.createElement("div", { className: "oceanum-text-empty" }, empty)) : (React.createElement("ul", { className: "oceanum-notebooks-list" }, items.map(item => (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-item", onClick: () => onOpen(item), ...rowAttributes(item) },
|
|
21
|
+
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))) : (React.createElement("div", { className: "oceanum-notebooks-item oceanum-notebooks-item-static", ...rowAttributes(item) },
|
|
22
|
+
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))))))))));
|
|
14
23
|
}
|
|
15
24
|
/**
|
|
16
25
|
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
17
26
|
*/
|
|
18
|
-
export function StoredNotebooks({ auth, onOpen }) {
|
|
27
|
+
export function StoredNotebooks({ auth, onOpen, onSignIn }) {
|
|
19
28
|
const [load, setLoad] = React.useState({ state: 'loading' });
|
|
20
29
|
const [user, setUser] = React.useState(auth.user);
|
|
30
|
+
// Bumped whenever the store changes, so the list is fetched again.
|
|
31
|
+
const [version, setVersion] = React.useState(0);
|
|
21
32
|
// Reload whenever the signed-in user changes, so signing in or out does not leave a
|
|
22
|
-
// stale list from the previous session on screen
|
|
33
|
+
// stale list from the previous session on screen; and whenever this extension has
|
|
34
|
+
// created, renamed or deleted a record.
|
|
23
35
|
React.useEffect(() => {
|
|
24
36
|
const onUser = () => setUser(auth.user);
|
|
37
|
+
const onChanged = () => setVersion(v => v + 1);
|
|
25
38
|
auth.userChanged.connect(onUser);
|
|
39
|
+
notebooksChanged.connect(onChanged);
|
|
26
40
|
return () => {
|
|
27
41
|
auth.userChanged.disconnect(onUser);
|
|
42
|
+
notebooksChanged.disconnect(onChanged);
|
|
28
43
|
};
|
|
29
44
|
}, [auth]);
|
|
30
45
|
React.useEffect(() => {
|
|
@@ -38,15 +53,11 @@ export function StoredNotebooks({ auth, onOpen }) {
|
|
|
38
53
|
setLoad({ state: 'loading' });
|
|
39
54
|
void (async () => {
|
|
40
55
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
setLoad({ state: 'ready', items: [] });
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const items = await listNotebooks(specs, token);
|
|
56
|
+
const client = new SpecStoreClient({
|
|
57
|
+
specsUrl: specs,
|
|
58
|
+
getAccessToken: () => auth.getAccessToken()
|
|
59
|
+
});
|
|
60
|
+
const items = await client.list();
|
|
50
61
|
if (!cancelled) {
|
|
51
62
|
setLoad({ state: 'ready', items });
|
|
52
63
|
}
|
|
@@ -65,9 +76,26 @@ export function StoredNotebooks({ auth, onOpen }) {
|
|
|
65
76
|
return () => {
|
|
66
77
|
cancelled = true;
|
|
67
78
|
};
|
|
68
|
-
}, [auth, user]);
|
|
79
|
+
}, [auth, user, version]);
|
|
69
80
|
if (!user) {
|
|
70
|
-
|
|
81
|
+
const signIn = () => {
|
|
82
|
+
if (onSignIn) {
|
|
83
|
+
onSignIn();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
auth.signIn().catch(error => {
|
|
87
|
+
console.warn('Oceanum.io sign-in could not start.', error);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
return (React.createElement("div", { className: "oceanum-text-empty" },
|
|
91
|
+
React.createElement("a", { role: "button", tabIndex: 0, onClick: signIn, onKeyDown: event => {
|
|
92
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
signIn();
|
|
95
|
+
}
|
|
96
|
+
} }, "Sign in"),
|
|
97
|
+
' ',
|
|
98
|
+
"to Oceanum.io to see your notebooks."));
|
|
71
99
|
}
|
|
72
100
|
if (load.state === 'loading') {
|
|
73
101
|
return React.createElement("div", { className: "oceanum-text-empty" }, "Loading\u2026");
|
|
@@ -77,7 +105,7 @@ export function StoredNotebooks({ auth, onOpen }) {
|
|
|
77
105
|
// the account has no notebooks when the load simply failed.
|
|
78
106
|
return React.createElement("div", { className: "oceanum-text-error" }, load.message);
|
|
79
107
|
}
|
|
80
|
-
const { mine, shared } =
|
|
108
|
+
const { mine, shared } = partitionSummaries(load.items, user.email);
|
|
81
109
|
return (React.createElement("div", { className: "oceanum-notebooks" },
|
|
82
110
|
React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
|
|
83
111
|
React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen })));
|
package/lib/auth/serverAuth.js
CHANGED
|
@@ -81,6 +81,7 @@ export function readServerAuthOption(option, hostname) {
|
|
|
81
81
|
clientId: config.clientId,
|
|
82
82
|
oceanumDomain: typeof config.oceanumDomain === 'string' ? config.oceanumDomain : '',
|
|
83
83
|
signInRedirect: false,
|
|
84
|
+
fileManagement: config.fileManagement === 'oceanum' ? 'oceanum' : 'local',
|
|
84
85
|
urls
|
|
85
86
|
};
|
|
86
87
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
2
|
+
import type { IOceanumAuth } from './tokens';
|
|
3
|
+
/**
|
|
4
|
+
* Start signing in the way this host shows it.
|
|
5
|
+
*
|
|
6
|
+
* On a JupyterLab server the device sign-in command both starts the flow and shows the
|
|
7
|
+
* code dialog; `auth.signIn()` alone starts the flow and shows nothing, so the user is
|
|
8
|
+
* left with a "Signing in…" top bar and no code. notebook.oceanum.io disables that
|
|
9
|
+
* command and provides an auth whose `signIn()` opens the Oceanum sign-in itself.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startSignIn(commands: CommandRegistry, auth: IOceanumAuth): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CommandIDs } from './accountWidget';
|
|
2
|
+
/**
|
|
3
|
+
* Start signing in the way this host shows it.
|
|
4
|
+
*
|
|
5
|
+
* On a JupyterLab server the device sign-in command both starts the flow and shows the
|
|
6
|
+
* code dialog; `auth.signIn()` alone starts the flow and shows nothing, so the user is
|
|
7
|
+
* left with a "Signing in…" top bar and no code. notebook.oceanum.io disables that
|
|
8
|
+
* command and provides an auth whose `signIn()` opens the Oceanum sign-in itself.
|
|
9
|
+
*/
|
|
10
|
+
export async function startSignIn(commands, auth) {
|
|
11
|
+
if (commands.hasCommand(CommandIDs.signIn)) {
|
|
12
|
+
await commands.execute(CommandIDs.signIn);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await auth.signIn();
|
|
16
|
+
}
|
package/lib/auth/tokens.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface IOceanumEnvironment {
|
|
|
24
24
|
* the site origin is an allowed callback URL.
|
|
25
25
|
*/
|
|
26
26
|
readonly signInRedirect: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether Oceanum.io is the only file management this host shows, as on
|
|
29
|
+
* notebook.oceanum.io: the file browser and the File menu's local-file entries go,
|
|
30
|
+
* and every local save is pushed to the spec store. `'local'` (the default when
|
|
31
|
+
* absent) keeps JupyterLab's file management alongside the Oceanum commands.
|
|
32
|
+
*/
|
|
33
|
+
readonly fileManagement?: 'local' | 'oceanum';
|
|
27
34
|
}
|
|
28
35
|
/** Service base URLs for an environment, without trailing slashes. */
|
|
29
36
|
export interface IOceanumServiceUrls {
|
package/lib/index.d.ts
CHANGED
|
@@ -19,5 +19,5 @@ export declare const oceanum_ai_extension: JupyterFrontEndPlugin<void>;
|
|
|
19
19
|
* JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
|
|
20
20
|
*/
|
|
21
21
|
export * from './auth/tokens';
|
|
22
|
-
declare const _default: (JupyterFrontEndPlugin<
|
|
22
|
+
declare const _default: (JupyterFrontEndPlugin<void> | JupyterFrontEndPlugin<IOceanumAuth>)[];
|
|
23
23
|
export default _default;
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { reporterFor } from './progress';
|
|
|
14
14
|
import { ConversationPin } from './conversationPin';
|
|
15
15
|
import { deviceAuthPlugin } from './auth/plugin';
|
|
16
16
|
import { IOceanumAuth } from './auth/tokens';
|
|
17
|
+
import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
|
|
17
18
|
import { snapshotFromIpynb, snapshotOf } from './notebookContext';
|
|
18
19
|
import { notebookHost } from './notebookHost';
|
|
19
20
|
import { KernelHandoff } from './kernelHandoff';
|
|
@@ -111,12 +112,12 @@ export const datamesh_connect_extension = {
|
|
|
111
112
|
settingsChanged,
|
|
112
113
|
commands: app.commands,
|
|
113
114
|
getCurrentWidget,
|
|
114
|
-
auth
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
auth,
|
|
116
|
+
openStoredNotebook: id => {
|
|
117
|
+
app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
|
|
118
|
+
console.error('Oceanum: could not open the stored notebook.', error);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
120
121
|
});
|
|
121
122
|
datameshConnectWidget.id = 'datamesh-connect';
|
|
122
123
|
datameshConnectWidget.title.icon = oceanumIcon;
|
|
@@ -327,6 +328,7 @@ export const oceanum_ai_extension = {
|
|
|
327
328
|
export * from './auth/tokens';
|
|
328
329
|
export default [
|
|
329
330
|
deviceAuthPlugin,
|
|
331
|
+
sharePlugin,
|
|
330
332
|
datamesh_connect_extension,
|
|
331
333
|
oceanum_ai_extension
|
|
332
334
|
];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ISpecBody, ISpecPatch, ISpecRecord, ISpecSummary } from './notebook';
|
|
2
|
+
export type ErrorKind = 'signed-out' | 'expired' | 'forbidden' | 'not-found' | 'too-large' | 'network' | 'invalid' | 'unsupported' | 'server';
|
|
3
|
+
/** A spec store failure with a message that is safe to show to the user. */
|
|
4
|
+
export declare class SpecStoreError extends Error {
|
|
5
|
+
readonly kind: ErrorKind;
|
|
6
|
+
readonly status: number | null;
|
|
7
|
+
constructor(kind: ErrorKind, status?: number | null);
|
|
8
|
+
}
|
|
9
|
+
/** Map a failed HTTP status to an error. */
|
|
10
|
+
export declare function errorForStatus(status: number): SpecStoreError;
|
|
11
|
+
export type SharePermission = 'read' | 'write';
|
|
12
|
+
/** A permission grant, as accepted by `POST /specs/notebook/{id}/permissions`. */
|
|
13
|
+
export type PermissionGrant = {
|
|
14
|
+
type: 'user';
|
|
15
|
+
entity: string;
|
|
16
|
+
permission: SharePermission;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'public';
|
|
19
|
+
entity: '';
|
|
20
|
+
permission: 'read';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* What the share dialog asks for: add grants, or remove one (e.g. stop public access).
|
|
24
|
+
*
|
|
25
|
+
* Granting carries a list because the dialog shares with several people at once, while
|
|
26
|
+
* the spec store takes one grant per request. `invalid` carries back anything typed that
|
|
27
|
+
* is not a shareable address, so the caller can name it rather than failing with
|
|
28
|
+
* "invalid email".
|
|
29
|
+
*/
|
|
30
|
+
export type ShareChoice = {
|
|
31
|
+
action: 'grant';
|
|
32
|
+
grants: PermissionGrant[];
|
|
33
|
+
invalid: string[];
|
|
34
|
+
} | {
|
|
35
|
+
action: 'revoke';
|
|
36
|
+
grant: PermissionGrant;
|
|
37
|
+
};
|
|
38
|
+
export interface ISpecStoreClientOptions {
|
|
39
|
+
/** Spec store base URL, e.g. `https://specs.oceanum.io`. */
|
|
40
|
+
specsUrl: string;
|
|
41
|
+
/** Returns a current access token or `null`. Called for every request. */
|
|
42
|
+
getAccessToken: () => Promise<string | null>;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
/** Client for the `notebook` spec type of the Oceanum spec store. */
|
|
46
|
+
export declare class SpecStoreClient {
|
|
47
|
+
constructor(options: ISpecStoreClientOptions);
|
|
48
|
+
/** Every notebook the user may read, including public ones. */
|
|
49
|
+
list(): Promise<ISpecSummary[]>;
|
|
50
|
+
/** Read a record, anonymously when signed out. */
|
|
51
|
+
get(id: string): Promise<ISpecRecord>;
|
|
52
|
+
/** Create a record. The server mints the id: use the returned record's. */
|
|
53
|
+
create(body: ISpecBody): Promise<ISpecRecord>;
|
|
54
|
+
/** Replace a record's name, description and notebook. */
|
|
55
|
+
update(id: string, body: ISpecBody): Promise<ISpecRecord>;
|
|
56
|
+
/**
|
|
57
|
+
* Change some of a record's fields and leave the rest as they are. Needs the same
|
|
58
|
+
* write access as `update`, which the store enforces a patch as.
|
|
59
|
+
*/
|
|
60
|
+
patch(id: string, changes: ISpecPatch): Promise<ISpecRecord>;
|
|
61
|
+
/**
|
|
62
|
+
* Give a record a new name, without sending the notebook back with it.
|
|
63
|
+
*
|
|
64
|
+
* Falls back to reading the record and putting it back where the store has no PATCH
|
|
65
|
+
* (production had none when this was written). That fallback races another writer's
|
|
66
|
+
* change to the notebook, which is the whole reason to prefer a patch.
|
|
67
|
+
*/
|
|
68
|
+
rename(id: string, name: string): Promise<ISpecRecord>;
|
|
69
|
+
/** Delete a record (requires admin access to it). */
|
|
70
|
+
remove(id: string): Promise<void>;
|
|
71
|
+
/** Grant a permission on a record (requires admin access to it). */
|
|
72
|
+
addPermission(id: string, grant: PermissionGrant): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Remove a permission (requires admin access). The server matches the exact grant and
|
|
75
|
+
* answers 404 when there is no such permission.
|
|
76
|
+
*/
|
|
77
|
+
removePermission(id: string, grant: PermissionGrant): Promise<void>;
|
|
78
|
+
private _url;
|
|
79
|
+
private _record;
|
|
80
|
+
private _request;
|
|
81
|
+
private readonly _base;
|
|
82
|
+
private readonly _getAccessToken;
|
|
83
|
+
private readonly _fetch;
|
|
84
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { isSpecId } from './notebook';
|
|
2
|
+
const MESSAGES = {
|
|
3
|
+
'signed-out': 'Sign in to Oceanum.io to continue.',
|
|
4
|
+
expired: 'Your Oceanum session has expired, sign in again.',
|
|
5
|
+
forbidden: 'You have no access to this notebook. It may not exist, or was created under another organisation.',
|
|
6
|
+
'not-found': 'This notebook no longer exists on Oceanum.io.',
|
|
7
|
+
'too-large': 'The notebook is too large for Oceanum.io (32 MiB limit).',
|
|
8
|
+
network: 'Could not reach Oceanum.io. Check your connection; very large notebooks can also fail this way.',
|
|
9
|
+
invalid: 'Oceanum.io returned an unexpected response.',
|
|
10
|
+
unsupported: 'This Oceanum.io service does not offer that operation yet.'
|
|
11
|
+
};
|
|
12
|
+
/** A spec store failure with a message that is safe to show to the user. */
|
|
13
|
+
export class SpecStoreError extends Error {
|
|
14
|
+
constructor(kind, status = null) {
|
|
15
|
+
super(kind === 'server'
|
|
16
|
+
? `Oceanum.io returned an error (HTTP ${status}).`
|
|
17
|
+
: MESSAGES[kind]);
|
|
18
|
+
this.kind = kind;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.name = 'SpecStoreError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Map a failed HTTP status to an error. */
|
|
24
|
+
export function errorForStatus(status) {
|
|
25
|
+
switch (status) {
|
|
26
|
+
// The spec store's authentication middleware answers a bad or expired token with 400.
|
|
27
|
+
case 400:
|
|
28
|
+
return new SpecStoreError('expired', status);
|
|
29
|
+
// Casbin answers 403 both for no access and for a record that does not exist.
|
|
30
|
+
case 403:
|
|
31
|
+
return new SpecStoreError('forbidden', status);
|
|
32
|
+
case 404:
|
|
33
|
+
return new SpecStoreError('not-found', status);
|
|
34
|
+
case 405:
|
|
35
|
+
// The route is there but not this method: a store from before it had PATCH.
|
|
36
|
+
return new SpecStoreError('unsupported', status);
|
|
37
|
+
case 413:
|
|
38
|
+
return new SpecStoreError('too-large', status);
|
|
39
|
+
default:
|
|
40
|
+
return new SpecStoreError('server', status);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isSummary(value) {
|
|
44
|
+
if (typeof value !== 'object' || value === null) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const v = value;
|
|
48
|
+
return (isSpecId(v.id) &&
|
|
49
|
+
typeof v.name === 'string' &&
|
|
50
|
+
typeof v.modified === 'string');
|
|
51
|
+
}
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return isSummary(value) && 'spec' in value;
|
|
54
|
+
}
|
|
55
|
+
/** Client for the `notebook` spec type of the Oceanum spec store. */
|
|
56
|
+
export class SpecStoreClient {
|
|
57
|
+
constructor(options) {
|
|
58
|
+
var _a;
|
|
59
|
+
this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/notebook`;
|
|
60
|
+
this._getAccessToken = options.getAccessToken;
|
|
61
|
+
this._fetch = (_a = options.fetch) !== null && _a !== void 0 ? _a : ((input, init) => fetch(input, init));
|
|
62
|
+
}
|
|
63
|
+
/** Every notebook the user may read, including public ones. */
|
|
64
|
+
async list() {
|
|
65
|
+
const data = await this._request('GET', this._base, { auth: 'required' });
|
|
66
|
+
if (!Array.isArray(data) || !data.every(isSummary)) {
|
|
67
|
+
throw new SpecStoreError('invalid');
|
|
68
|
+
}
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
/** Read a record, anonymously when signed out. */
|
|
72
|
+
async get(id) {
|
|
73
|
+
return this._record(await this._request('GET', this._url(id), { auth: 'optional' }));
|
|
74
|
+
}
|
|
75
|
+
/** Create a record. The server mints the id: use the returned record's. */
|
|
76
|
+
async create(body) {
|
|
77
|
+
return this._record(await this._request('POST', this._base, {
|
|
78
|
+
auth: 'required',
|
|
79
|
+
body: pick(body)
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
/** Replace a record's name, description and notebook. */
|
|
83
|
+
async update(id, body) {
|
|
84
|
+
return this._record(await this._request('PUT', this._url(id), {
|
|
85
|
+
auth: 'required',
|
|
86
|
+
body: pick(body)
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Change some of a record's fields and leave the rest as they are. Needs the same
|
|
91
|
+
* write access as `update`, which the store enforces a patch as.
|
|
92
|
+
*/
|
|
93
|
+
async patch(id, changes) {
|
|
94
|
+
if (Object.keys(changes).length === 0) {
|
|
95
|
+
// The store answers an empty patch with 400; there is nothing to send anyway.
|
|
96
|
+
throw new SpecStoreError('invalid');
|
|
97
|
+
}
|
|
98
|
+
return this._record(await this._request('PATCH', this._url(id), {
|
|
99
|
+
auth: 'required',
|
|
100
|
+
body: changes
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Give a record a new name, without sending the notebook back with it.
|
|
105
|
+
*
|
|
106
|
+
* Falls back to reading the record and putting it back where the store has no PATCH
|
|
107
|
+
* (production had none when this was written). That fallback races another writer's
|
|
108
|
+
* change to the notebook, which is the whole reason to prefer a patch.
|
|
109
|
+
*/
|
|
110
|
+
async rename(id, name) {
|
|
111
|
+
try {
|
|
112
|
+
return await this.patch(id, { name });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (!(error instanceof SpecStoreError) || error.kind !== 'unsupported') {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const record = await this.get(id);
|
|
119
|
+
return this.update(id, {
|
|
120
|
+
name,
|
|
121
|
+
description: record.description,
|
|
122
|
+
spec: record.spec
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Delete a record (requires admin access to it). */
|
|
127
|
+
async remove(id) {
|
|
128
|
+
await this._request('DELETE', this._url(id), { auth: 'required' });
|
|
129
|
+
}
|
|
130
|
+
/** Grant a permission on a record (requires admin access to it). */
|
|
131
|
+
async addPermission(id, grant) {
|
|
132
|
+
await this._request('POST', `${this._url(id)}/permissions`, {
|
|
133
|
+
auth: 'required',
|
|
134
|
+
body: {
|
|
135
|
+
type: grant.type,
|
|
136
|
+
entity: grant.entity,
|
|
137
|
+
permission: grant.permission
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Remove a permission (requires admin access). The server matches the exact grant and
|
|
143
|
+
* answers 404 when there is no such permission.
|
|
144
|
+
*/
|
|
145
|
+
async removePermission(id, grant) {
|
|
146
|
+
await this._request('DELETE', `${this._url(id)}/permissions`, {
|
|
147
|
+
auth: 'required',
|
|
148
|
+
body: {
|
|
149
|
+
type: grant.type,
|
|
150
|
+
entity: grant.entity,
|
|
151
|
+
permission: grant.permission
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
_url(id) {
|
|
156
|
+
if (!isSpecId(id)) {
|
|
157
|
+
throw new SpecStoreError('invalid');
|
|
158
|
+
}
|
|
159
|
+
return `${this._base}/${id}`;
|
|
160
|
+
}
|
|
161
|
+
_record(data) {
|
|
162
|
+
if (!isRecord(data)) {
|
|
163
|
+
throw new SpecStoreError('invalid');
|
|
164
|
+
}
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
async _request(method, url, options) {
|
|
168
|
+
const token = await this._getAccessToken();
|
|
169
|
+
if (token === null && options.auth === 'required') {
|
|
170
|
+
throw new SpecStoreError('signed-out');
|
|
171
|
+
}
|
|
172
|
+
const headers = {};
|
|
173
|
+
if (token !== null) {
|
|
174
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
175
|
+
}
|
|
176
|
+
const init = { method, headers };
|
|
177
|
+
if (options.body !== undefined) {
|
|
178
|
+
headers['Content-Type'] = 'application/json';
|
|
179
|
+
init.body = JSON.stringify(options.body);
|
|
180
|
+
}
|
|
181
|
+
let response;
|
|
182
|
+
try {
|
|
183
|
+
response = await this._fetch(url, init);
|
|
184
|
+
}
|
|
185
|
+
catch (_a) {
|
|
186
|
+
throw new SpecStoreError('network');
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw errorForStatus(response.status);
|
|
190
|
+
}
|
|
191
|
+
if (response.status === 204) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return await response.json();
|
|
196
|
+
}
|
|
197
|
+
catch (_b) {
|
|
198
|
+
throw new SpecStoreError('invalid');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Only the fields the server accepts; in particular never send an `id`. */
|
|
203
|
+
function pick(body) {
|
|
204
|
+
return { name: body.name, description: body.description, spec: body.spec };
|
|
205
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Signal } from '@lumino/signaling';
|
|
2
|
+
/**
|
|
3
|
+
* Emits after this extension creates, renames or deletes a notebook record, so the
|
|
4
|
+
* Notebooks tab can list again. A plain module-level signal: the tab and the commands
|
|
5
|
+
* live in different plugins, and the change is the only thing they need to share.
|
|
6
|
+
*/
|
|
7
|
+
export declare const notebooksChanged: Signal<object, void>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Signal } from '@lumino/signaling';
|
|
2
|
+
/**
|
|
3
|
+
* Emits after this extension creates, renames or deletes a notebook record, so the
|
|
4
|
+
* Notebooks tab can list again. A plain module-level signal: the tab and the commands
|
|
5
|
+
* live in different plugins, and the change is the only thing they need to share.
|
|
6
|
+
*/
|
|
7
|
+
// The sender is a throwaway object, not null: Lumino keys its receivers by sender in a
|
|
8
|
+
// WeakMap, which refuses null.
|
|
9
|
+
export const notebooksChanged = new Signal({});
|