@larc-iu/plaid-client 0.2.0-alpha.0 → 0.2.0-alpha.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/index.d.ts +91 -9
- package/package.json +1 -1
- package/src/http.js +16 -7
- package/src/index.js +382 -45
- package/src/provenance.js +21 -5
- package/src/serviceSchema.js +2 -0
- package/src/services.js +11 -1
- package/src/sse.js +20 -1
package/index.d.ts
CHANGED
|
@@ -164,6 +164,11 @@ interface UsersBundle {
|
|
|
164
164
|
delete(id: string, auditMessage?: string): Promise<any>;
|
|
165
165
|
activate(id: string, auditMessage?: string): Promise<any>;
|
|
166
166
|
update(id: string, password?: string, username?: string, isAdmin?: boolean, auditMessage?: string): Promise<any>;
|
|
167
|
+
/** URL for a user's profile picture, usable as an <img> src. Null when avatarHash is explicitly null. */
|
|
168
|
+
avatarUrl(id: string, avatarHash?: string | null): string | null;
|
|
169
|
+
getAvatar(id: string): Promise<any>;
|
|
170
|
+
setAvatar(id: string, file: File | Blob, auditMessage?: string): Promise<any>;
|
|
171
|
+
deleteAvatar(id: string, auditMessage?: string): Promise<any>;
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
interface ApiTokensBundle {
|
|
@@ -174,6 +179,59 @@ interface ApiTokensBundle {
|
|
|
174
179
|
revoke(userId: string, tokenId: string, auditMessage?: string): Promise<any>;
|
|
175
180
|
}
|
|
176
181
|
|
|
182
|
+
interface Invite {
|
|
183
|
+
id: string;
|
|
184
|
+
/** "signup" | "password-reset" */
|
|
185
|
+
kind: string;
|
|
186
|
+
/** "active" | "used" | "expired" | "revoked" */
|
|
187
|
+
status: string;
|
|
188
|
+
createdBy: string;
|
|
189
|
+
createdAt: string;
|
|
190
|
+
expiresAt: string;
|
|
191
|
+
maxUses: number;
|
|
192
|
+
uses: number;
|
|
193
|
+
revokedAt: string | null;
|
|
194
|
+
note: string | null;
|
|
195
|
+
targetUserId: string | null;
|
|
196
|
+
grantAdmin: boolean;
|
|
197
|
+
projectId: string | null;
|
|
198
|
+
projectRole: string | null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface InvitePreview {
|
|
202
|
+
/** "signup" | "password-reset" */
|
|
203
|
+
kind: string;
|
|
204
|
+
/** "active" | "used" | "expired" | "revoked" */
|
|
205
|
+
status: string;
|
|
206
|
+
expiresAt: string;
|
|
207
|
+
grantAdmin: boolean;
|
|
208
|
+
projectName?: string;
|
|
209
|
+
projectRole?: string;
|
|
210
|
+
/** Present only for a password reset: whose account the link belongs to. */
|
|
211
|
+
username?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface CreateInviteOptions {
|
|
215
|
+
projectId?: string;
|
|
216
|
+
/** "reader" | "writer" | "maintainer" */
|
|
217
|
+
projectRole?: string;
|
|
218
|
+
grantAdmin?: boolean;
|
|
219
|
+
/** Makes this a password reset for that user (admin only). */
|
|
220
|
+
targetUserId?: string;
|
|
221
|
+
maxUses?: number;
|
|
222
|
+
ttlDays?: number;
|
|
223
|
+
note?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface InvitesBundle {
|
|
227
|
+
list(opts?: { projectId?: string }): Promise<Invite[]>;
|
|
228
|
+
listPage(opts?: { projectId?: string; limit?: number; cursor?: string }): Promise<Page>;
|
|
229
|
+
iterPages(opts?: { projectId?: string; pageSize?: number }): AsyncGenerator<Invite[]>;
|
|
230
|
+
/** The `code` is returned ONCE and is not recoverable afterward. */
|
|
231
|
+
create(opts?: CreateInviteOptions, auditMessage?: string): Promise<Invite & { code: string }>;
|
|
232
|
+
revoke(id: string, auditMessage?: string): Promise<any>;
|
|
233
|
+
}
|
|
234
|
+
|
|
177
235
|
interface TokenLayersBundle {
|
|
178
236
|
shift(tokenLayerId: string, direction: string, auditMessage?: string): Promise<any>;
|
|
179
237
|
create(textLayerId: string, name: string, overlapMode?: string, parentTokenLayerId?: string, auditMessage?: string): Promise<any>;
|
|
@@ -295,9 +353,25 @@ interface PlaidClientOptions {
|
|
|
295
353
|
onAuthError?: ((error: Error) => void) | null;
|
|
296
354
|
}
|
|
297
355
|
|
|
356
|
+
export interface OperationGroupsBundle {
|
|
357
|
+
get(id: string): Promise<any>;
|
|
358
|
+
update(id: string, message: string | null): Promise<any>;
|
|
359
|
+
}
|
|
360
|
+
|
|
298
361
|
export declare class PlaidClient {
|
|
299
362
|
constructor(baseUrl: string, token: string, options?: PlaidClientOptions);
|
|
300
363
|
static login(baseUrl: string, userId: string, password: string, options?: PlaidClientOptions): Promise<PlaidClient>;
|
|
364
|
+
/** Build the link to hand someone for an invite code. */
|
|
365
|
+
static inviteUrl(appUrl: string, code: string): string;
|
|
366
|
+
/** Describe an invite code with no authentication (for a signup page). */
|
|
367
|
+
static lookupInvite(baseUrl: string, code: string, options?: PlaidClientOptions): Promise<InvitePreview>;
|
|
368
|
+
/** Redeem an invite code with no authentication; resolves to a logged-in client. */
|
|
369
|
+
static redeemInvite(
|
|
370
|
+
baseUrl: string,
|
|
371
|
+
code: string,
|
|
372
|
+
credentials: { username?: string; password: string },
|
|
373
|
+
options?: PlaidClientOptions,
|
|
374
|
+
): Promise<{ client: PlaidClient; userId: string; kind: string }>;
|
|
301
375
|
timeout: number | null;
|
|
302
376
|
/** Fired once on HTTP 401 (see PlaidClientOptions.onAuthError). */
|
|
303
377
|
onAuthError: ((error: Error) => void) | null;
|
|
@@ -314,12 +388,14 @@ export declare class PlaidClient {
|
|
|
314
388
|
enterStrictMode(documentId: string): void;
|
|
315
389
|
exitStrictMode(): void;
|
|
316
390
|
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
391
|
+
// Logical operations (audit-log grouping). While one is open every write is
|
|
392
|
+
// stamped with its group id so the audit log folds them into one entry.
|
|
393
|
+
// Not atomic; nesting flattens into the outer operation.
|
|
394
|
+
operationGroup: { id: string; message: string | null } | null;
|
|
395
|
+
beginOperation(message: string, opts?: { id?: string }): string;
|
|
396
|
+
endOperation(message?: string): Promise<void>;
|
|
397
|
+
withOperation<T>(message: string, fn: (setMessage: (msg: string) => void) => Promise<T> | T): Promise<T>;
|
|
398
|
+
operationGroups: OperationGroupsBundle;
|
|
323
399
|
|
|
324
400
|
// Query
|
|
325
401
|
query(body: any, auditMessage?: string): Promise<any>;
|
|
@@ -333,6 +409,7 @@ export declare class PlaidClient {
|
|
|
333
409
|
texts: TextsBundle;
|
|
334
410
|
users: UsersBundle;
|
|
335
411
|
apiTokens: ApiTokensBundle;
|
|
412
|
+
invites: InvitesBundle;
|
|
336
413
|
tokenLayers: TokenLayersBundle;
|
|
337
414
|
documents: DocumentsBundle;
|
|
338
415
|
messages: MessagesBundle;
|
|
@@ -392,6 +469,7 @@ export const TASKS: {
|
|
|
392
469
|
readonly PARSE: 'parse';
|
|
393
470
|
readonly TRANSCRIBE: 'transcribe';
|
|
394
471
|
readonly LINK_VOCAB: 'link-vocab';
|
|
472
|
+
readonly ANALYZE: 'analyze';
|
|
395
473
|
};
|
|
396
474
|
/** Whether a service serves a task (declared `extras.tasks`, legacy id-prefix fallback). */
|
|
397
475
|
export function servesTask(service: DiscoveredService, task: string): boolean;
|
|
@@ -442,9 +520,13 @@ export function confirmedInferred(source: string, extras?: ProvExtras):
|
|
|
442
520
|
{ prov: 'inferred'; provSource: string; provConfirmed: true; provProb?: number; provDetail?: Record<string, any> };
|
|
443
521
|
/** Classify an entity's metadata into one of the three provenance states. */
|
|
444
522
|
export function provState(metadata: object | null | undefined): ProvState;
|
|
445
|
-
/**
|
|
523
|
+
/** The verifying fragment, { provConfirmed: true }: PATCH it over existing metadata. */
|
|
524
|
+
export const PROV_CONFIRMED: { readonly provConfirmed: true };
|
|
525
|
+
/** Machine-made and not yet human-verified (needs review, replaceable, confirmable). */
|
|
526
|
+
export function isMachine(metadata: object | null | undefined): boolean;
|
|
527
|
+
/** Whether a machine writer must leave this entity alone (human or verified). !isMachine. */
|
|
446
528
|
export function isProtected(metadata: object | null | undefined): boolean;
|
|
447
|
-
/** The fragment a HUMAN edit should merge in:
|
|
448
|
-
export function verifyOnEdit(metadata: object | null | undefined): { provConfirmed: true } | null;
|
|
529
|
+
/** The fragment a HUMAN edit should merge in: PROV_CONFIRMED iff machine-unverified, else null. */
|
|
530
|
+
export function verifyOnEdit(metadata: object | null | undefined): { readonly provConfirmed: true } | null;
|
|
449
531
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
450
532
|
export function serviceSource(serviceId: string): string;
|
package/package.json
CHANGED
package/src/http.js
CHANGED
|
@@ -157,16 +157,25 @@ export async function makeRequest(client, method, path, options = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// OCC self-conflict, so it is stamped on EVERY queued batch op, not just the
|
|
160
|
+
// Per-call custom audit-log message (overrides the auto-generated
|
|
161
|
+
// description of THIS write). Unlike document-version this has no OCC
|
|
162
|
+
// self-conflict, so it is stamped on every queued batch op, not just the
|
|
164
163
|
// first. The server templates `{param}` placeholders against the endpoint's
|
|
165
164
|
// own path/query/body params.
|
|
166
|
-
|
|
167
|
-
if (effectiveAuditMessage && method !== 'GET') {
|
|
165
|
+
if (auditMessage && method !== 'GET') {
|
|
168
166
|
const separator = url.includes('?') ? '&' : '?';
|
|
169
|
-
url += `${separator}audit-message=${encodeURIComponent(
|
|
167
|
+
url += `${separator}audit-message=${encodeURIComponent(auditMessage)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Logical-operation group (see client.beginOperation): stamp every write
|
|
171
|
+
// with the group id; the message rides along too so the server can label
|
|
172
|
+
// the group lazily on whichever tagged write lands first.
|
|
173
|
+
if (client.operationGroup && method !== 'GET') {
|
|
174
|
+
const group = client.operationGroup;
|
|
175
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
176
|
+
url += `${separator}group-id=${encodeURIComponent(group.id)}`;
|
|
177
|
+
if (group.message) url += `&group-message=${encodeURIComponent(group.message)}`;
|
|
178
|
+
group.written = true;
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
// Batch mode
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,24 @@ import {
|
|
|
16
16
|
requestService,
|
|
17
17
|
} from './services.js';
|
|
18
18
|
|
|
19
|
+
// Helper: normalize a document-read `layers` filter to the wire's
|
|
20
|
+
// comma-separated form. Accepts an array of layer ids or a ready-made string;
|
|
21
|
+
// anything empty becomes undefined so no `?layers=` is sent at all.
|
|
22
|
+
function layersParam(layers) {
|
|
23
|
+
if (layers === undefined || layers === null) return undefined;
|
|
24
|
+
const joined = Array.isArray(layers) ? layers.join(',') : String(layers);
|
|
25
|
+
return joined.length > 0 ? joined : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper: normalize an audit `opTypes` filter to the wire's comma-separated
|
|
29
|
+
// form. Accepts an array of op types or a ready-made string; anything empty
|
|
30
|
+
// becomes undefined so no `?op-types=` is sent at all.
|
|
31
|
+
function opTypesParam(opTypes) {
|
|
32
|
+
if (opTypes === undefined || opTypes === null) return undefined;
|
|
33
|
+
const joined = Array.isArray(opTypes) ? opTypes.join(',') : String(opTypes);
|
|
34
|
+
return joined.length > 0 ? joined : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
// Helper: build body object, filtering out undefined values
|
|
20
38
|
function bodyOf(obj) {
|
|
21
39
|
const result = {};
|
|
@@ -25,6 +43,32 @@ function bodyOf(obj) {
|
|
|
25
43
|
return result;
|
|
26
44
|
}
|
|
27
45
|
|
|
46
|
+
// POST to an endpoint that takes no Authorization header (login, invite lookup
|
|
47
|
+
// and redemption). Deliberately does not go through PlaidClient._request: these
|
|
48
|
+
// are called before any client exists, which is the whole point.
|
|
49
|
+
async function anonymousPost(baseUrl, path, body, options = {}) {
|
|
50
|
+
const base = baseUrl.replace(/\/$/, '');
|
|
51
|
+
const url = `${base}${path}`;
|
|
52
|
+
try {
|
|
53
|
+
const fetchOptions = {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
};
|
|
58
|
+
const signal = timeoutSignal(options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS);
|
|
59
|
+
if (signal) fetchOptions.signal = signal;
|
|
60
|
+
|
|
61
|
+
const response = await fetch(url, fetchOptions);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
|
|
64
|
+
}
|
|
65
|
+
return transformResponse(await response.json());
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.status) throw error;
|
|
68
|
+
throw makeNetworkError(error, url, 'POST');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
28
72
|
class PlaidClient {
|
|
29
73
|
/**
|
|
30
74
|
* Create a new PlaidClient instance
|
|
@@ -41,9 +85,11 @@ class PlaidClient {
|
|
|
41
85
|
this.batchOperations = [];
|
|
42
86
|
this.documentVersions = {};
|
|
43
87
|
this.strictModeDocumentId = null;
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
88
|
+
// The open logical operation (audit-log group), or null. While set, every
|
|
89
|
+
// write is stamped with `?group-id=` (+ `group-message`) so the audit log
|
|
90
|
+
// folds them into ONE expandable entry. See beginOperation / withOperation.
|
|
91
|
+
// Shape: { id, message, depth, written, refined }.
|
|
92
|
+
this.operationGroup = null;
|
|
47
93
|
// Optional callback fired once (per client) when any request returns HTTP
|
|
48
94
|
// 401 — i.e. the token is missing/expired/invalid. Apps use it to discard
|
|
49
95
|
// the stored token and route back to login. See makeRequest in http.js.
|
|
@@ -601,10 +647,19 @@ class PlaidClient {
|
|
|
601
647
|
* @param {string} [startTime] - Start of time range
|
|
602
648
|
* @param {string} [endTime] - End of time range
|
|
603
649
|
* @param {string} [asOf] - Temporal query timestamp
|
|
650
|
+
* @param {string[]|string} [opTypes] - Only return operations of these
|
|
651
|
+
* types, spelled as in an entry's `op/type` (e.g.
|
|
652
|
+
* `['span-layer/create', 'span-layer/delete']`). An entry appears when
|
|
653
|
+
* one of its operations matches, carrying only the ones that did.
|
|
604
654
|
*/
|
|
605
|
-
audit: (userId, startTime, endTime, asOf) =>
|
|
655
|
+
audit: (userId, startTime, endTime, asOf, opTypes) =>
|
|
606
656
|
listAll(this, `/api/v1/users/${userId}/audit`, {
|
|
607
|
-
query: {
|
|
657
|
+
query: {
|
|
658
|
+
'start-time': startTime,
|
|
659
|
+
'end-time': endTime,
|
|
660
|
+
'as-of': asOf,
|
|
661
|
+
'op-types': opTypesParam(opTypes),
|
|
662
|
+
},
|
|
608
663
|
}),
|
|
609
664
|
/**
|
|
610
665
|
* Get a user by ID
|
|
@@ -646,6 +701,60 @@ class PlaidClient {
|
|
|
646
701
|
this._request('PATCH', `/api/v1/users/${id}`, { auditMessage,
|
|
647
702
|
body: bodyOf({ password, username, 'is-admin': isAdmin }),
|
|
648
703
|
}),
|
|
704
|
+
/**
|
|
705
|
+
* Build a URL for a user's profile picture, suitable for use directly as
|
|
706
|
+
* an `<img>` src. An image element cannot send an Authorization header,
|
|
707
|
+
* so the session token rides in the query string, the same way document
|
|
708
|
+
* media does.
|
|
709
|
+
*
|
|
710
|
+
* Pass the user record's `avatarHash` as the second argument whenever you
|
|
711
|
+
* have it: the URL then addresses that exact picture, so the browser can
|
|
712
|
+
* cache it indefinitely and still pick up a replacement the moment the
|
|
713
|
+
* user changes it. Returns null when the user has no picture, so callers
|
|
714
|
+
* can fall back to initials without a wasted request.
|
|
715
|
+
* @param {string} id - The user ID
|
|
716
|
+
* @param {string} [avatarHash] - The user record's `avatarHash`
|
|
717
|
+
* @returns {string|null}
|
|
718
|
+
*/
|
|
719
|
+
avatarUrl: (id, avatarHash) => {
|
|
720
|
+
if (avatarHash === null) return null;
|
|
721
|
+
const params = new URLSearchParams({ token: this.token });
|
|
722
|
+
if (avatarHash) params.set('v', avatarHash);
|
|
723
|
+
return `${this.baseUrl}/api/v1/users/${id}/avatar?${params}`;
|
|
724
|
+
},
|
|
725
|
+
/**
|
|
726
|
+
* Fetch a user's profile picture as raw bytes. Most callers want
|
|
727
|
+
* avatarUrl() instead. This one is for non-browser consumers.
|
|
728
|
+
* @param {string} id - The user ID
|
|
729
|
+
*/
|
|
730
|
+
getAvatar: (id) =>
|
|
731
|
+
this._request('GET', `/api/v1/users/${id}/avatar`, {
|
|
732
|
+
noBatch: true,
|
|
733
|
+
binaryResponse: true,
|
|
734
|
+
}),
|
|
735
|
+
/**
|
|
736
|
+
* Upload a profile picture. Your own, or anyone's if you are an admin.
|
|
737
|
+
* The server center-crops to a square, scales to the configured edge
|
|
738
|
+
* length, and re-encodes, so no client-side resizing is needed. Accepts
|
|
739
|
+
* PNG, JPEG, WebP, and GIF. Resolves to the updated user record.
|
|
740
|
+
* @param {string} id - The user ID
|
|
741
|
+
* @param {File} file - The image to upload
|
|
742
|
+
*/
|
|
743
|
+
setAvatar: (id, file, auditMessage) => {
|
|
744
|
+
const fd = new FormData();
|
|
745
|
+
fd.append('file', file);
|
|
746
|
+
return this._request('PUT', `/api/v1/users/${id}/avatar`, { auditMessage,
|
|
747
|
+
body: fd, formData: true, noBatch: true,
|
|
748
|
+
});
|
|
749
|
+
},
|
|
750
|
+
/**
|
|
751
|
+
* Remove a profile picture. Your own, or anyone's if you are an admin.
|
|
752
|
+
* @param {string} id - The user ID
|
|
753
|
+
*/
|
|
754
|
+
deleteAvatar: (id, auditMessage) =>
|
|
755
|
+
this._request('DELETE', `/api/v1/users/${id}/avatar`, { auditMessage,
|
|
756
|
+
noBatch: true,
|
|
757
|
+
}),
|
|
649
758
|
};
|
|
650
759
|
|
|
651
760
|
this.apiTokens = {
|
|
@@ -700,6 +809,74 @@ class PlaidClient {
|
|
|
700
809
|
this._request('DELETE', `/api/v1/users/${userId}/tokens/${tokenId}`, { auditMessage }),
|
|
701
810
|
};
|
|
702
811
|
|
|
812
|
+
this.invites = {
|
|
813
|
+
/**
|
|
814
|
+
* List invites you minted, oldest first. With `projectId`, lists that
|
|
815
|
+
* project's invites instead (including co-maintainers'), which requires
|
|
816
|
+
* maintainer or admin on it. Never includes invite codes — the code is
|
|
817
|
+
* returned once, by create(), and is not recoverable afterward.
|
|
818
|
+
* Transparently follows pagination cursors and returns the full flat array.
|
|
819
|
+
* Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
|
|
820
|
+
* @param {object} [opts]
|
|
821
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
822
|
+
*/
|
|
823
|
+
list: ({ projectId } = {}) =>
|
|
824
|
+
listAll(this, '/api/v1/invites', { query: { 'project-id': projectId } }),
|
|
825
|
+
/**
|
|
826
|
+
* Fetch a single page of invites.
|
|
827
|
+
* @param {object} [opts]
|
|
828
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
829
|
+
* @param {number} [opts.limit] - Page size (1..1000; server default 100)
|
|
830
|
+
* @param {string} [opts.cursor] - Opaque cursor from a previous page
|
|
831
|
+
* @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
|
|
832
|
+
*/
|
|
833
|
+
listPage: ({ projectId, limit, cursor } = {}) =>
|
|
834
|
+
listPage(this, '/api/v1/invites', { limit, cursor, query: { 'project-id': projectId } }),
|
|
835
|
+
/**
|
|
836
|
+
* Async-iterate invites page by page; yields each page's entries array.
|
|
837
|
+
* @param {object} [opts]
|
|
838
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
839
|
+
* @param {number} [opts.pageSize] - Per-request page size
|
|
840
|
+
* Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
|
|
841
|
+
* @returns {AsyncGenerator<Array>}
|
|
842
|
+
*/
|
|
843
|
+
iterPages: ({ projectId, pageSize } = {}) =>
|
|
844
|
+
iterPages(this, '/api/v1/invites', { pageSize, query: { 'project-id': projectId } }),
|
|
845
|
+
/**
|
|
846
|
+
* Mint an invite. The returned `code` is shown ONLY here — it is never
|
|
847
|
+
* stored and cannot be recovered, so build and hand off the link now.
|
|
848
|
+
* Use PlaidClient.inviteUrl() to turn it into one.
|
|
849
|
+
*
|
|
850
|
+
* Admins may mint anything. A project maintainer may mint role grants on
|
|
851
|
+
* projects they maintain, and nothing else: no admin grant, no grantless
|
|
852
|
+
* invite, no password resets.
|
|
853
|
+
* @param {object} opts
|
|
854
|
+
* @param {string} [opts.projectId] - Project the redeemer joins (with projectRole)
|
|
855
|
+
* @param {string} [opts.projectRole] - "reader" | "writer" | "maintainer"
|
|
856
|
+
* @param {boolean} [opts.grantAdmin] - Make the new account a global admin (admin only)
|
|
857
|
+
* @param {string} [opts.targetUserId] - Make this a password reset for that user (admin only)
|
|
858
|
+
* @param {number} [opts.maxUses] - How many accounts this link may create (default 1)
|
|
859
|
+
* @param {number} [opts.ttlDays] - Days until it expires (default 14, max 365)
|
|
860
|
+
* @param {string} [opts.note] - Human label shown in your invite list
|
|
861
|
+
* @returns {Promise<{id: string, code: string, kind: string, status: string}>}
|
|
862
|
+
*/
|
|
863
|
+
create: ({ projectId, projectRole, grantAdmin, targetUserId, maxUses, ttlDays, note } = {}, auditMessage) =>
|
|
864
|
+
this._request('POST', '/api/v1/invites', { auditMessage,
|
|
865
|
+
body: bodyOf({
|
|
866
|
+
'project-id': projectId, 'project-role': projectRole,
|
|
867
|
+
'grant-admin': grantAdmin, 'target-user-id': targetUserId,
|
|
868
|
+
'max-uses': maxUses, 'ttl-days': ttlDays, note,
|
|
869
|
+
}),
|
|
870
|
+
}),
|
|
871
|
+
/**
|
|
872
|
+
* Revoke an invite, killing the link immediately. Idempotent. Allowed for
|
|
873
|
+
* the creator, an admin, or a maintainer of the invite's project.
|
|
874
|
+
* @param {string} id - The invite ID
|
|
875
|
+
*/
|
|
876
|
+
revoke: (id, auditMessage) =>
|
|
877
|
+
this._request('DELETE', `/api/v1/invites/${id}`, { auditMessage }),
|
|
878
|
+
};
|
|
879
|
+
|
|
703
880
|
this.tokenLayers = {
|
|
704
881
|
/**
|
|
705
882
|
* Shift a token layer's display order.
|
|
@@ -855,20 +1032,42 @@ class PlaidClient {
|
|
|
855
1032
|
* @param {string} [startTime] - Start of time range
|
|
856
1033
|
* @param {string} [endTime] - End of time range
|
|
857
1034
|
* @param {string} [asOf] - Temporal query timestamp
|
|
1035
|
+
* @param {string[]|string} [opTypes] - Only return operations of these
|
|
1036
|
+
* types, spelled as in an entry's `op/type` (e.g.
|
|
1037
|
+
* `['span-layer/create', 'span-layer/delete']`). An entry appears when
|
|
1038
|
+
* one of its operations matches, carrying only the ones that did.
|
|
858
1039
|
*/
|
|
859
|
-
audit: (documentId, startTime, endTime, asOf) =>
|
|
1040
|
+
audit: (documentId, startTime, endTime, asOf, opTypes) =>
|
|
860
1041
|
listAll(this, `/api/v1/documents/${documentId}/audit`, {
|
|
861
|
-
query: {
|
|
1042
|
+
query: {
|
|
1043
|
+
'start-time': startTime,
|
|
1044
|
+
'end-time': endTime,
|
|
1045
|
+
'as-of': asOf,
|
|
1046
|
+
'op-types': opTypesParam(opTypes),
|
|
1047
|
+
},
|
|
862
1048
|
}),
|
|
863
1049
|
/**
|
|
864
1050
|
* Get a document. Set `includeBody` to true to include all data.
|
|
1051
|
+
*
|
|
1052
|
+
* `layers` narrows a body read to the layers you name (ids of any kind:
|
|
1053
|
+
* text, token, span, or relation). A layer comes back when it is named
|
|
1054
|
+
* or is an ancestor of a named layer, and carries its own
|
|
1055
|
+
* texts/tokens/spans/relations/vocabs only when it is itself named — so
|
|
1056
|
+
* name the text layer too if you also want the text body. An id that is
|
|
1057
|
+
* not a layer of this document's project is an error, not a quietly
|
|
1058
|
+
* smaller response. Requires `includeBody`.
|
|
865
1059
|
* @param {string} documentId - The document ID
|
|
866
1060
|
* @param {boolean} [includeBody] - Include document body data
|
|
867
1061
|
* @param {string} [asOf] - Temporal query timestamp
|
|
1062
|
+
* @param {string[]|string} [layers] - Layer ids to restrict a body read to
|
|
868
1063
|
*/
|
|
869
|
-
get: (documentId, includeBody, asOf) =>
|
|
1064
|
+
get: (documentId, includeBody, asOf, layers) =>
|
|
870
1065
|
this._request('GET', `/api/v1/documents/${documentId}`, {
|
|
871
|
-
queryParams: {
|
|
1066
|
+
queryParams: {
|
|
1067
|
+
'include-body': includeBody,
|
|
1068
|
+
'as-of': asOf,
|
|
1069
|
+
layers: layersParam(layers),
|
|
1070
|
+
},
|
|
872
1071
|
}),
|
|
873
1072
|
/**
|
|
874
1073
|
* Delete a document and all data contained.
|
|
@@ -969,10 +1168,19 @@ class PlaidClient {
|
|
|
969
1168
|
* @param {string} [startTime] - Start of time range
|
|
970
1169
|
* @param {string} [endTime] - End of time range
|
|
971
1170
|
* @param {string} [asOf] - Temporal query timestamp
|
|
1171
|
+
* @param {string[]|string} [opTypes] - Only return operations of these
|
|
1172
|
+
* types, spelled as in an entry's `op/type` (e.g.
|
|
1173
|
+
* `['span-layer/create', 'span-layer/delete']`). An entry appears when
|
|
1174
|
+
* one of its operations matches, carrying only the ones that did.
|
|
972
1175
|
*/
|
|
973
|
-
audit: (projectId, startTime, endTime, asOf) =>
|
|
1176
|
+
audit: (projectId, startTime, endTime, asOf, opTypes) =>
|
|
974
1177
|
listAll(this, `/api/v1/projects/${projectId}/audit`, {
|
|
975
|
-
query: {
|
|
1178
|
+
query: {
|
|
1179
|
+
'start-time': startTime,
|
|
1180
|
+
'end-time': endTime,
|
|
1181
|
+
'as-of': asOf,
|
|
1182
|
+
'op-types': opTypesParam(opTypes),
|
|
1183
|
+
},
|
|
976
1184
|
}),
|
|
977
1185
|
/**
|
|
978
1186
|
* Link a vocabulary to a project.
|
|
@@ -1445,14 +1653,21 @@ class PlaidClient {
|
|
|
1445
1653
|
createSSEConnection(this, projectId, onEvent, path),
|
|
1446
1654
|
|
|
1447
1655
|
/**
|
|
1448
|
-
* Send a message to project listeners
|
|
1656
|
+
* Send a message to project listeners.
|
|
1657
|
+
*
|
|
1658
|
+
* `data` may be any JSON value and is sent VERBATIM (`rawBody`): a
|
|
1659
|
+
* message payload is opaque application data, like `metadata` and
|
|
1660
|
+
* `config`, so its keys must not be re-cased on the way out. Without
|
|
1661
|
+
* this a key such as `case-marker` would reach listeners as
|
|
1662
|
+
* `caseMarker` in JS and `case_marker` in Python. `listen` restores it
|
|
1663
|
+
* verbatim on the way in.
|
|
1449
1664
|
* @param {string} projectId - The UUID of the project to send to
|
|
1450
1665
|
* @param {any} data - The message data to send
|
|
1451
1666
|
* @returns {Promise<any>} Response from the send operation
|
|
1452
1667
|
*/
|
|
1453
1668
|
sendMessage: (projectId, data, auditMessage) =>
|
|
1454
1669
|
this._request('POST', `/api/v1/projects/${projectId}/message`, { auditMessage,
|
|
1455
|
-
|
|
1670
|
+
rawBody: { body: data },
|
|
1456
1671
|
}),
|
|
1457
1672
|
|
|
1458
1673
|
/**
|
|
@@ -1536,6 +1751,25 @@ class PlaidClient {
|
|
|
1536
1751
|
*/
|
|
1537
1752
|
this.query = (body, auditMessage) =>
|
|
1538
1753
|
this._request('POST', '/api/v1/query', { auditMessage, body });
|
|
1754
|
+
|
|
1755
|
+
// Logical-operation groups (audit-log grouping). There is no create: a
|
|
1756
|
+
// group row is made lazily by the first write carrying `?group-id=`
|
|
1757
|
+
// (see beginOperation). Not an audited write, so no auditMessage arg.
|
|
1758
|
+
this.operationGroups = {
|
|
1759
|
+
/**
|
|
1760
|
+
* Get a logical-operation group (its label + creator).
|
|
1761
|
+
* @param {string} id - The group id
|
|
1762
|
+
*/
|
|
1763
|
+
get: (id) =>
|
|
1764
|
+
this._request('GET', `/api/v1/operation-groups/${id}`),
|
|
1765
|
+
/**
|
|
1766
|
+
* Relabel a logical-operation group after the fact. Owner or admin only.
|
|
1767
|
+
* @param {string} id - The group id
|
|
1768
|
+
* @param {string|null} message - The new label
|
|
1769
|
+
*/
|
|
1770
|
+
update: (id, message) =>
|
|
1771
|
+
this._request('PATCH', `/api/v1/operation-groups/${id}`, { body: { message } }),
|
|
1772
|
+
};
|
|
1539
1773
|
}
|
|
1540
1774
|
|
|
1541
1775
|
// --- Core methods ---
|
|
@@ -1559,51 +1793,94 @@ class PlaidClient {
|
|
|
1559
1793
|
}
|
|
1560
1794
|
|
|
1561
1795
|
/**
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1796
|
+
* Begin a LOGICAL OPERATION: a user-meaningful action ("Merge morphemes",
|
|
1797
|
+
* "Re-transcribe") implemented as many low-level writes, possibly across
|
|
1798
|
+
* several batches and even a service round-trip. Until `endOperation()`,
|
|
1799
|
+
* every write is stamped with a client-minted `?group-id=` (and the message)
|
|
1800
|
+
* so the audit log shows the whole run as ONE expandable entry labeled
|
|
1801
|
+
* `message`, with each write's own description underneath.
|
|
1802
|
+
*
|
|
1803
|
+
* Grouping is orthogonal to batches: a batch is a transaction boundary, an
|
|
1804
|
+
* operation is an intent boundary. An operation is NOT atomic — if write 3
|
|
1805
|
+
* of 5 fails, writes 1–2 stay committed (and logged under the group). Use a
|
|
1806
|
+
* batch inside the operation for any step that must be all-or-nothing.
|
|
1807
|
+
*
|
|
1808
|
+
* Nesting flattens: a `beginOperation` while one is open is a no-op that
|
|
1809
|
+
* joins the outer operation (the outer label wins), and the matching
|
|
1810
|
+
* `endOperation` is likewise a no-op. The label is recorded on the FIRST
|
|
1811
|
+
* write, so an operation that is never ended (crash, closed tab) is still
|
|
1812
|
+
* labeled in the log.
|
|
1813
|
+
*
|
|
1814
|
+
* @param {string} message - Human label for the operation.
|
|
1815
|
+
* @param {object} [opts] - Optional `{ id }`: adopt an existing group id instead of minting one (a service joining the requester's operation; `requestService` propagates an open operation to the service automatically).
|
|
1816
|
+
* @returns {string} The operation's group id.
|
|
1571
1817
|
*/
|
|
1572
|
-
|
|
1573
|
-
this.
|
|
1574
|
-
|
|
1818
|
+
beginOperation(message, { id } = {}) {
|
|
1819
|
+
if (this.operationGroup) {
|
|
1820
|
+
this.operationGroup.depth += 1;
|
|
1821
|
+
return this.operationGroup.id;
|
|
1822
|
+
}
|
|
1823
|
+
this.operationGroup = {
|
|
1824
|
+
id: id || crypto.randomUUID(),
|
|
1825
|
+
message: message == null ? null : String(message),
|
|
1826
|
+
depth: 1,
|
|
1827
|
+
written: false,
|
|
1828
|
+
refined: undefined,
|
|
1829
|
+
};
|
|
1830
|
+
return this.operationGroup.id;
|
|
1575
1831
|
}
|
|
1576
1832
|
|
|
1577
|
-
/**
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1833
|
+
/**
|
|
1834
|
+
* End the current logical operation. With no argument this is purely local
|
|
1835
|
+
* (no request). Pass a refined `message` to relabel the group now that the
|
|
1836
|
+
* outcome is known (e.g. `endOperation('Merged 3 morphemes')`) — that sends
|
|
1837
|
+
* one PATCH, skipped if the operation never wrote anything. A refine from a
|
|
1838
|
+
* nested (flattened) `endOperation` is ignored; the outer label wins.
|
|
1839
|
+
* @param {string} [message] - Optional refined label.
|
|
1840
|
+
* @returns {Promise<void>}
|
|
1841
|
+
*/
|
|
1842
|
+
async endOperation(message) {
|
|
1843
|
+
const group = this.operationGroup;
|
|
1844
|
+
if (!group) return;
|
|
1845
|
+
if (group.depth > 1) {
|
|
1846
|
+
group.depth -= 1;
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
this.operationGroup = null;
|
|
1850
|
+
const refined = message !== undefined ? message : group.refined;
|
|
1851
|
+
if (refined !== undefined && group.written) {
|
|
1852
|
+
try {
|
|
1853
|
+
await this.operationGroups.update(group.id, refined);
|
|
1854
|
+
} catch (e) {
|
|
1855
|
+
// 404: the group never materialized server-side (every tagged write
|
|
1856
|
+
// failed or a batch was aborted). Nothing to relabel — not an error.
|
|
1857
|
+
if (e.status !== 404) throw e;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1581
1860
|
}
|
|
1582
1861
|
|
|
1583
1862
|
/**
|
|
1584
|
-
* Run `fn`
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* await client.withAuditMessage('Approve span {spanId}', () =>
|
|
1589
|
-
* client.spans.update(spanId, ...));
|
|
1863
|
+
* Run `fn` as one logical operation (see beginOperation), ending it when
|
|
1864
|
+
* `fn` settles — including on throw. `fn` receives a `setMessage(msg)`
|
|
1865
|
+
* callback to refine the label once the outcome is known.
|
|
1590
1866
|
*
|
|
1591
|
-
* await client.
|
|
1592
|
-
* await client.
|
|
1593
|
-
*
|
|
1867
|
+
* await client.withOperation('Merge morphemes', async (setMessage) => {
|
|
1868
|
+
* await client.batched(() => { ... });
|
|
1869
|
+
* setMessage(`Merged ${n} morphemes`);
|
|
1594
1870
|
* });
|
|
1595
1871
|
*
|
|
1596
|
-
* @param {string} message -
|
|
1597
|
-
* @param {
|
|
1872
|
+
* @param {string} message - Human label for the operation.
|
|
1873
|
+
* @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
|
|
1598
1874
|
* @returns {Promise<any>} Whatever `fn` resolves to.
|
|
1599
1875
|
*/
|
|
1600
|
-
async
|
|
1601
|
-
|
|
1602
|
-
|
|
1876
|
+
async withOperation(message, fn) {
|
|
1877
|
+
this.beginOperation(message);
|
|
1878
|
+
const group = this.operationGroup;
|
|
1879
|
+
const setMessage = (msg) => { if (group.depth === 1) group.refined = msg; };
|
|
1603
1880
|
try {
|
|
1604
|
-
return await fn();
|
|
1881
|
+
return await fn(setMessage);
|
|
1605
1882
|
} finally {
|
|
1606
|
-
this.
|
|
1883
|
+
await this.endOperation();
|
|
1607
1884
|
}
|
|
1608
1885
|
}
|
|
1609
1886
|
|
|
@@ -1739,6 +2016,64 @@ class PlaidClient {
|
|
|
1739
2016
|
* @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
|
|
1740
2017
|
* @returns {Promise<PlaidClient>} - Authenticated client instance
|
|
1741
2018
|
*/
|
|
2019
|
+
/**
|
|
2020
|
+
* Build the link to hand someone for an invite code. The server never sees
|
|
2021
|
+
* an app URL, so the app that minted the invite is the one that names it.
|
|
2022
|
+
* Both SPAs use HashRouter, so the code rides in the fragment — which also
|
|
2023
|
+
* keeps it out of server access logs.
|
|
2024
|
+
* @param {string} appUrl - Where the SPA lives, e.g. "https://plaid.example.org/igt/"
|
|
2025
|
+
* @param {string} code - The code returned by invites.create()
|
|
2026
|
+
* @returns {string}
|
|
2027
|
+
*/
|
|
2028
|
+
static inviteUrl(appUrl, code) {
|
|
2029
|
+
return `${appUrl.replace(/#.*$/, '').replace(/\/$/, '')}/#/invite/${encodeURIComponent(code)}`;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Describe an invite code, with NO authentication — this is what a signup
|
|
2034
|
+
* page calls before the redeemer has an account. Returns the kind of link
|
|
2035
|
+
* ("signup" or "password-reset"), its status ("active", "used", "expired",
|
|
2036
|
+
* "revoked"), and the project it grants access to, if any.
|
|
2037
|
+
*
|
|
2038
|
+
* Throws a 404-shaped error if the code is unknown. A known-but-dead code
|
|
2039
|
+
* resolves normally with a non-"active" status, so the page can say why.
|
|
2040
|
+
* @param {string} baseUrl - The API base URL
|
|
2041
|
+
* @param {string} code - The invite code
|
|
2042
|
+
* @param {object} [options]
|
|
2043
|
+
* @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, username?: string, grantAdmin: boolean}>}
|
|
2044
|
+
*/
|
|
2045
|
+
static async lookupInvite(baseUrl, code, options = {}) {
|
|
2046
|
+
return anonymousPost(baseUrl, '/api/v1/invite-codes/lookup', { code }, options);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Redeem an invite code, with NO authentication.
|
|
2051
|
+
*
|
|
2052
|
+
* For a signup invite, pass `username` and `password` to create the account;
|
|
2053
|
+
* the invite's grants are applied in the same transaction. For a password
|
|
2054
|
+
* reset link, pass `password` only. Resolves to a logged-in client, exactly
|
|
2055
|
+
* like login() — the redeemer just chose these credentials, so there is no
|
|
2056
|
+
* reason to send them to a login form to retype them.
|
|
2057
|
+
* @param {string} baseUrl - The API base URL
|
|
2058
|
+
* @param {string} code - The invite code
|
|
2059
|
+
* @param {object} credentials
|
|
2060
|
+
* @param {string} [credentials.username] - Desired username (signup only)
|
|
2061
|
+
* @param {string} credentials.password - Desired password (min 8 characters)
|
|
2062
|
+
* @param {object} [options]
|
|
2063
|
+
* @returns {Promise<{client: PlaidClient, userId: string, kind: string}>}
|
|
2064
|
+
*/
|
|
2065
|
+
static async redeemInvite(baseUrl, code, { username, password } = {}, options = {}) {
|
|
2066
|
+
const data = await anonymousPost(
|
|
2067
|
+
baseUrl, '/api/v1/invite-codes/redeem',
|
|
2068
|
+
bodyOf({ code, username, password }), options,
|
|
2069
|
+
);
|
|
2070
|
+
return {
|
|
2071
|
+
client: new PlaidClient(baseUrl.replace(/\/$/, ''), data.token || '', options),
|
|
2072
|
+
userId: data.userId,
|
|
2073
|
+
kind: data.kind,
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1742
2077
|
static async login(baseUrl, userId, password, options = {}) {
|
|
1743
2078
|
baseUrl = baseUrl.replace(/\/$/, '');
|
|
1744
2079
|
const url = `${baseUrl}/api/v1/login`;
|
|
@@ -1792,9 +2127,11 @@ export {
|
|
|
1792
2127
|
export {
|
|
1793
2128
|
PROV,
|
|
1794
2129
|
PROV_STATES,
|
|
2130
|
+
PROV_CONFIRMED,
|
|
1795
2131
|
stampInferred,
|
|
1796
2132
|
confirmedInferred,
|
|
1797
2133
|
provState,
|
|
2134
|
+
isMachine,
|
|
1798
2135
|
isProtected,
|
|
1799
2136
|
verifyOnEdit,
|
|
1800
2137
|
serviceSource,
|
package/src/provenance.js
CHANGED
|
@@ -95,6 +95,16 @@ export const confirmedInferred = (source, extras = {}) => ({
|
|
|
95
95
|
[PROV.confirmedKey]: true,
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The metadata fragment that verifies machine-made material:
|
|
100
|
+
* { provConfirmed: true }. Merge (PATCH) it over existing metadata so
|
|
101
|
+
* prov/provSource/provProb/provDetail survive. This is what verifyOnEdit
|
|
102
|
+
* returns for machine-unverified material; use it directly for explicit
|
|
103
|
+
* confirmation gestures (a "confirm" button, confirm-on-touch) where the
|
|
104
|
+
* caller has already checked isMachine.
|
|
105
|
+
*/
|
|
106
|
+
export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
|
|
107
|
+
|
|
98
108
|
/**
|
|
99
109
|
* Classify an entity's metadata into one of PROV_STATES.
|
|
100
110
|
* @param {Object|null|undefined} metadata
|
|
@@ -105,19 +115,25 @@ export const provState = (metadata) => {
|
|
|
105
115
|
return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
|
|
106
116
|
};
|
|
107
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Whether this entity is machine-made and not yet human-verified — the
|
|
120
|
+
* material that still needs review, that machine writers may replace, and
|
|
121
|
+
* that a human confirmation gesture acts on. Complement of isProtected.
|
|
122
|
+
*/
|
|
123
|
+
export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
|
|
124
|
+
|
|
108
125
|
/**
|
|
109
126
|
* Whether a machine writer must leave this entity alone (write-contract
|
|
110
127
|
* rule 2): true for human-made and human-verified material.
|
|
111
128
|
*/
|
|
112
|
-
export const isProtected = (metadata) =>
|
|
129
|
+
export const isProtected = (metadata) => !isMachine(metadata);
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
132
|
* The metadata fragment a HUMAN edit of this entity should merge in
|
|
116
|
-
* (write-contract rule 3):
|
|
117
|
-
*
|
|
133
|
+
* (write-contract rule 3): PROV_CONFIRMED when the entity is machine-made
|
|
134
|
+
* and unverified, else null (nothing to do).
|
|
118
135
|
*/
|
|
119
|
-
export const verifyOnEdit = (metadata) =>
|
|
120
|
-
(provState(metadata) === PROV_STATES.MACHINE ? { [PROV.confirmedKey]: true } : null);
|
|
136
|
+
export const verifyOnEdit = (metadata) => (isMachine(metadata) ? PROV_CONFIRMED : null);
|
|
121
137
|
|
|
122
138
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
123
139
|
export const serviceSource = (serviceId) => `service:${serviceId}`;
|
package/src/serviceSchema.js
CHANGED
|
@@ -48,6 +48,8 @@ export const TASKS = Object.freeze({
|
|
|
48
48
|
TRANSCRIBE: 'transcribe',
|
|
49
49
|
/** Create vocab links for unlinked tokens. */
|
|
50
50
|
LINK_VOCAB: 'link-vocab',
|
|
51
|
+
/** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
|
|
52
|
+
ANALYZE: 'analyze',
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
/**
|
package/src/services.js
CHANGED
|
@@ -147,6 +147,16 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
147
147
|
* @returns {Promise<any>} The service's result
|
|
148
148
|
*/
|
|
149
149
|
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
|
|
150
|
+
// Propagate an open logical operation (client.beginOperation) to the
|
|
151
|
+
// service: its writes then fold under the requester's audit-log entry
|
|
152
|
+
// (the Python BaseService adopts the id around process_request). Only for a
|
|
153
|
+
// plain-object payload — that's the only shape the service param schema
|
|
154
|
+
// delivers anyway.
|
|
155
|
+
const group = client.operationGroup;
|
|
156
|
+
const payload =
|
|
157
|
+
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
158
|
+
? { ...data, operationGroup: { id: group.id, message: group.message } }
|
|
159
|
+
: data;
|
|
150
160
|
return new Promise((resolve, reject) => {
|
|
151
161
|
const abortController = new AbortController();
|
|
152
162
|
let settled = false;
|
|
@@ -174,7 +184,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
174
184
|
'Content-Type': 'application/json',
|
|
175
185
|
'Accept': 'text/event-stream',
|
|
176
186
|
},
|
|
177
|
-
body: JSON.stringify(
|
|
187
|
+
body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
|
|
178
188
|
signal: abortController.signal,
|
|
179
189
|
},
|
|
180
190
|
);
|
package/src/sse.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { transformResponse } from './transforms.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Project a parsed SSE `data:` object onto what a listener callback sees.
|
|
5
|
+
*
|
|
6
|
+
* The envelope is API surface and gets the usual `transformResponse` recasing.
|
|
7
|
+
* A `message` event's `data` is NOT: it is opaque application data (whatever
|
|
8
|
+
* `sendMessage` was handed), so its keys must survive verbatim, exactly as
|
|
9
|
+
* `metadata` and `config` do elsewhere. Without this, a key written
|
|
10
|
+
* `case-marker` reaches a JavaScript listener as `caseMarker` and a Python
|
|
11
|
+
* listener as `case_marker`, so the same broadcast reads differently depending
|
|
12
|
+
* on the reader's language.
|
|
13
|
+
*/
|
|
14
|
+
export function eventPayload(eventType, parsed) {
|
|
15
|
+
const payload = transformResponse(parsed);
|
|
16
|
+
if (eventType === 'message' && parsed && typeof parsed === 'object' && 'data' in parsed) {
|
|
17
|
+
payload.data = parsed.data;
|
|
18
|
+
}
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
/**
|
|
4
23
|
* Create an SSE connection to the listen endpoint using fetch-based streaming.
|
|
5
24
|
* Automatically handles heartbeat confirmations and event parsing.
|
|
@@ -124,7 +143,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
124
143
|
sendHeartbeatConfirmation();
|
|
125
144
|
} else {
|
|
126
145
|
const parsedData = JSON.parse(data);
|
|
127
|
-
const shouldStop = onEvent(eventType,
|
|
146
|
+
const shouldStop = onEvent(eventType, eventPayload(eventType, parsedData));
|
|
128
147
|
if (shouldStop === true) {
|
|
129
148
|
sseConnection.close();
|
|
130
149
|
return;
|