@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.
Files changed (45) hide show
  1. package/lib/DatameshWidget.js +14 -5
  2. package/lib/StoredNotebooks.d.ts +9 -5
  3. package/lib/StoredNotebooks.js +49 -21
  4. package/lib/auth/serverAuth.js +1 -0
  5. package/lib/auth/startSignIn.d.ts +11 -0
  6. package/lib/auth/startSignIn.js +16 -0
  7. package/lib/auth/tokens.d.ts +7 -0
  8. package/lib/index.d.ts +1 -1
  9. package/lib/index.js +8 -6
  10. package/lib/share/client.d.ts +84 -0
  11. package/lib/share/client.js +205 -0
  12. package/lib/share/events.d.ts +7 -0
  13. package/lib/share/events.js +9 -0
  14. package/lib/share/notebook.d.ts +184 -0
  15. package/lib/share/notebook.js +277 -0
  16. package/lib/share/oceanumOnly.d.ts +49 -0
  17. package/lib/share/oceanumOnly.js +90 -0
  18. package/lib/share/plugin.d.ts +21 -0
  19. package/lib/share/plugin.js +883 -0
  20. package/lib/share/widgets.d.ts +25 -0
  21. package/lib/share/widgets.js +154 -0
  22. package/package.json +5 -1
  23. package/src/DatameshWidget.tsx +21 -2
  24. package/src/StoredNotebooks.tsx +60 -20
  25. package/src/__tests__/OceanumPanel.spec.tsx +60 -4
  26. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  27. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  28. package/src/__tests__/shareClient.spec.ts +314 -0
  29. package/src/__tests__/shareNotebook.spec.ts +451 -0
  30. package/src/__tests__/sharePlugin.spec.ts +426 -0
  31. package/src/auth/serverAuth.ts +1 -0
  32. package/src/auth/startSignIn.ts +23 -0
  33. package/src/auth/tokens.ts +7 -0
  34. package/src/index.ts +8 -6
  35. package/src/share/client.ts +288 -0
  36. package/src/share/events.ts +10 -0
  37. package/src/share/notebook.ts +416 -0
  38. package/src/share/oceanumOnly.ts +126 -0
  39. package/src/share/plugin.ts +1096 -0
  40. package/src/share/widgets.ts +217 -0
  41. package/style/index.css +111 -16
  42. package/lib/specStore.d.ts +0 -37
  43. package/lib/specStore.js +0 -57
  44. package/src/__tests__/specStore.spec.ts +0 -126
  45. package/src/specStore.ts +0 -82
@@ -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 })) : (React.createElement("div", { className: "oceanum-text-empty" }, "Oceanum.io sign-in is not configured for this host."))
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
- if (access.source) {
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
  /**
@@ -1,16 +1,20 @@
1
1
  import * as React from 'react';
2
2
  import { IOceanumAuth } from './auth/tokens';
3
- import { ISpecSummary } from './specStore';
3
+ import { ISpecSummary } from './share/notebook';
4
4
  export interface IStoredNotebooksProps {
5
5
  auth: IOceanumAuth;
6
6
  /**
7
- * Open a stored notebook, when this host can. Undefined where nothing has registered
8
- * a way to open one — today that is native JupyterLab, which has no spec store drive
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;
@@ -1,30 +1,45 @@
1
1
  import * as React from 'react';
2
- import { listNotebooks, partitionNotebooks } from './specStore';
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
- var _a, _b;
10
- return (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-item", onClick: () => onOpen(item), title: (_a = item.description) !== null && _a !== void 0 ? _a : undefined },
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 token = await auth.getAccessToken();
42
- if (cancelled) {
43
- return;
44
- }
45
- if (!token) {
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
- return (React.createElement("div", { className: "oceanum-text-empty" }, "Sign in to Oceanum.io to see your notebooks."));
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 } = partitionNotebooks(load.items, user.email);
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 })));
@@ -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
+ }
@@ -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<IOceanumAuth> | JupyterFrontEndPlugin<void>)[];
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
- // No `openStoredNotebook`: nothing can open a spec store record by id yet.
116
- // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
117
- // its own picker — so there is nothing to hand an id to. Opening arrives with
118
- // the drive in OCE-182, where the contents API makes it the ordinary path.
119
- // Until then the list reads rather than offering a click that does nothing.
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({});