@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
|
@@ -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>({});
|