@larc-iu/plaid-client 0.2.0-alpha.0 → 0.2.0-alpha.1
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 +90 -9
- package/package.json +1 -1
- package/src/http.js +16 -7
- package/src/index.js +307 -35
- package/src/provenance.js +21 -5
- package/src/services.js +11 -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;
|
|
@@ -442,9 +519,13 @@ export function confirmedInferred(source: string, extras?: ProvExtras):
|
|
|
442
519
|
{ prov: 'inferred'; provSource: string; provConfirmed: true; provProb?: number; provDetail?: Record<string, any> };
|
|
443
520
|
/** Classify an entity's metadata into one of the three provenance states. */
|
|
444
521
|
export function provState(metadata: object | null | undefined): ProvState;
|
|
445
|
-
/**
|
|
522
|
+
/** The verifying fragment, { provConfirmed: true }: PATCH it over existing metadata. */
|
|
523
|
+
export const PROV_CONFIRMED: { readonly provConfirmed: true };
|
|
524
|
+
/** Machine-made and not yet human-verified (needs review, replaceable, confirmable). */
|
|
525
|
+
export function isMachine(metadata: object | null | undefined): boolean;
|
|
526
|
+
/** Whether a machine writer must leave this entity alone (human or verified). !isMachine. */
|
|
446
527
|
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;
|
|
528
|
+
/** The fragment a HUMAN edit should merge in: PROV_CONFIRMED iff machine-unverified, else null. */
|
|
529
|
+
export function verifyOnEdit(metadata: object | null | undefined): { readonly provConfirmed: true } | null;
|
|
449
530
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
450
531
|
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
|
@@ -25,6 +25,32 @@ function bodyOf(obj) {
|
|
|
25
25
|
return result;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// POST to an endpoint that takes no Authorization header (login, invite lookup
|
|
29
|
+
// and redemption). Deliberately does not go through PlaidClient._request: these
|
|
30
|
+
// are called before any client exists, which is the whole point.
|
|
31
|
+
async function anonymousPost(baseUrl, path, body, options = {}) {
|
|
32
|
+
const base = baseUrl.replace(/\/$/, '');
|
|
33
|
+
const url = `${base}${path}`;
|
|
34
|
+
try {
|
|
35
|
+
const fetchOptions = {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
};
|
|
40
|
+
const signal = timeoutSignal(options.timeout !== undefined ? options.timeout : DEFAULT_TIMEOUT_MS);
|
|
41
|
+
if (signal) fetchOptions.signal = signal;
|
|
42
|
+
|
|
43
|
+
const response = await fetch(url, fetchOptions);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw makeHttpError(response, await parseErrorBody(response), url, 'POST');
|
|
46
|
+
}
|
|
47
|
+
return transformResponse(await response.json());
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.status) throw error;
|
|
50
|
+
throw makeNetworkError(error, url, 'POST');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
class PlaidClient {
|
|
29
55
|
/**
|
|
30
56
|
* Create a new PlaidClient instance
|
|
@@ -41,9 +67,11 @@ class PlaidClient {
|
|
|
41
67
|
this.batchOperations = [];
|
|
42
68
|
this.documentVersions = {};
|
|
43
69
|
this.strictModeDocumentId = null;
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
70
|
+
// The open logical operation (audit-log group), or null. While set, every
|
|
71
|
+
// write is stamped with `?group-id=` (+ `group-message`) so the audit log
|
|
72
|
+
// folds them into ONE expandable entry. See beginOperation / withOperation.
|
|
73
|
+
// Shape: { id, message, depth, written, refined }.
|
|
74
|
+
this.operationGroup = null;
|
|
47
75
|
// Optional callback fired once (per client) when any request returns HTTP
|
|
48
76
|
// 401 — i.e. the token is missing/expired/invalid. Apps use it to discard
|
|
49
77
|
// the stored token and route back to login. See makeRequest in http.js.
|
|
@@ -646,6 +674,60 @@ class PlaidClient {
|
|
|
646
674
|
this._request('PATCH', `/api/v1/users/${id}`, { auditMessage,
|
|
647
675
|
body: bodyOf({ password, username, 'is-admin': isAdmin }),
|
|
648
676
|
}),
|
|
677
|
+
/**
|
|
678
|
+
* Build a URL for a user's profile picture, suitable for use directly as
|
|
679
|
+
* an `<img>` src. An image element cannot send an Authorization header,
|
|
680
|
+
* so the session token rides in the query string, the same way document
|
|
681
|
+
* media does.
|
|
682
|
+
*
|
|
683
|
+
* Pass the user record's `avatarHash` as the second argument whenever you
|
|
684
|
+
* have it: the URL then addresses that exact picture, so the browser can
|
|
685
|
+
* cache it indefinitely and still pick up a replacement the moment the
|
|
686
|
+
* user changes it. Returns null when the user has no picture, so callers
|
|
687
|
+
* can fall back to initials without a wasted request.
|
|
688
|
+
* @param {string} id - The user ID
|
|
689
|
+
* @param {string} [avatarHash] - The user record's `avatarHash`
|
|
690
|
+
* @returns {string|null}
|
|
691
|
+
*/
|
|
692
|
+
avatarUrl: (id, avatarHash) => {
|
|
693
|
+
if (avatarHash === null) return null;
|
|
694
|
+
const params = new URLSearchParams({ token: this.token });
|
|
695
|
+
if (avatarHash) params.set('v', avatarHash);
|
|
696
|
+
return `${this.baseUrl}/api/v1/users/${id}/avatar?${params}`;
|
|
697
|
+
},
|
|
698
|
+
/**
|
|
699
|
+
* Fetch a user's profile picture as raw bytes. Most callers want
|
|
700
|
+
* avatarUrl() instead. This one is for non-browser consumers.
|
|
701
|
+
* @param {string} id - The user ID
|
|
702
|
+
*/
|
|
703
|
+
getAvatar: (id) =>
|
|
704
|
+
this._request('GET', `/api/v1/users/${id}/avatar`, {
|
|
705
|
+
noBatch: true,
|
|
706
|
+
binaryResponse: true,
|
|
707
|
+
}),
|
|
708
|
+
/**
|
|
709
|
+
* Upload a profile picture. Your own, or anyone's if you are an admin.
|
|
710
|
+
* The server center-crops to a square, scales to the configured edge
|
|
711
|
+
* length, and re-encodes, so no client-side resizing is needed. Accepts
|
|
712
|
+
* PNG, JPEG, WebP, and GIF. Resolves to the updated user record.
|
|
713
|
+
* @param {string} id - The user ID
|
|
714
|
+
* @param {File} file - The image to upload
|
|
715
|
+
*/
|
|
716
|
+
setAvatar: (id, file, auditMessage) => {
|
|
717
|
+
const fd = new FormData();
|
|
718
|
+
fd.append('file', file);
|
|
719
|
+
return this._request('PUT', `/api/v1/users/${id}/avatar`, { auditMessage,
|
|
720
|
+
body: fd, formData: true, noBatch: true,
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
/**
|
|
724
|
+
* Remove a profile picture. Your own, or anyone's if you are an admin.
|
|
725
|
+
* @param {string} id - The user ID
|
|
726
|
+
*/
|
|
727
|
+
deleteAvatar: (id, auditMessage) =>
|
|
728
|
+
this._request('DELETE', `/api/v1/users/${id}/avatar`, { auditMessage,
|
|
729
|
+
noBatch: true,
|
|
730
|
+
}),
|
|
649
731
|
};
|
|
650
732
|
|
|
651
733
|
this.apiTokens = {
|
|
@@ -700,6 +782,74 @@ class PlaidClient {
|
|
|
700
782
|
this._request('DELETE', `/api/v1/users/${userId}/tokens/${tokenId}`, { auditMessage }),
|
|
701
783
|
};
|
|
702
784
|
|
|
785
|
+
this.invites = {
|
|
786
|
+
/**
|
|
787
|
+
* List invites you minted, oldest first. With `projectId`, lists that
|
|
788
|
+
* project's invites instead (including co-maintainers'), which requires
|
|
789
|
+
* maintainer or admin on it. Never includes invite codes — the code is
|
|
790
|
+
* returned once, by create(), and is not recoverable afterward.
|
|
791
|
+
* Transparently follows pagination cursors and returns the full flat array.
|
|
792
|
+
* Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
|
|
793
|
+
* @param {object} [opts]
|
|
794
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
795
|
+
*/
|
|
796
|
+
list: ({ projectId } = {}) =>
|
|
797
|
+
listAll(this, '/api/v1/invites', { query: { 'project-id': projectId } }),
|
|
798
|
+
/**
|
|
799
|
+
* Fetch a single page of invites.
|
|
800
|
+
* @param {object} [opts]
|
|
801
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
802
|
+
* @param {number} [opts.limit] - Page size (1..1000; server default 100)
|
|
803
|
+
* @param {string} [opts.cursor] - Opaque cursor from a previous page
|
|
804
|
+
* @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
|
|
805
|
+
*/
|
|
806
|
+
listPage: ({ projectId, limit, cursor } = {}) =>
|
|
807
|
+
listPage(this, '/api/v1/invites', { limit, cursor, query: { 'project-id': projectId } }),
|
|
808
|
+
/**
|
|
809
|
+
* Async-iterate invites page by page; yields each page's entries array.
|
|
810
|
+
* @param {object} [opts]
|
|
811
|
+
* @param {string} [opts.projectId] - List this project's invites instead of your own
|
|
812
|
+
* @param {number} [opts.pageSize] - Per-request page size
|
|
813
|
+
* 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.
|
|
814
|
+
* @returns {AsyncGenerator<Array>}
|
|
815
|
+
*/
|
|
816
|
+
iterPages: ({ projectId, pageSize } = {}) =>
|
|
817
|
+
iterPages(this, '/api/v1/invites', { pageSize, query: { 'project-id': projectId } }),
|
|
818
|
+
/**
|
|
819
|
+
* Mint an invite. The returned `code` is shown ONLY here — it is never
|
|
820
|
+
* stored and cannot be recovered, so build and hand off the link now.
|
|
821
|
+
* Use PlaidClient.inviteUrl() to turn it into one.
|
|
822
|
+
*
|
|
823
|
+
* Admins may mint anything. A project maintainer may mint role grants on
|
|
824
|
+
* projects they maintain, and nothing else: no admin grant, no grantless
|
|
825
|
+
* invite, no password resets.
|
|
826
|
+
* @param {object} opts
|
|
827
|
+
* @param {string} [opts.projectId] - Project the redeemer joins (with projectRole)
|
|
828
|
+
* @param {string} [opts.projectRole] - "reader" | "writer" | "maintainer"
|
|
829
|
+
* @param {boolean} [opts.grantAdmin] - Make the new account a global admin (admin only)
|
|
830
|
+
* @param {string} [opts.targetUserId] - Make this a password reset for that user (admin only)
|
|
831
|
+
* @param {number} [opts.maxUses] - How many accounts this link may create (default 1)
|
|
832
|
+
* @param {number} [opts.ttlDays] - Days until it expires (default 14, max 365)
|
|
833
|
+
* @param {string} [opts.note] - Human label shown in your invite list
|
|
834
|
+
* @returns {Promise<{id: string, code: string, kind: string, status: string}>}
|
|
835
|
+
*/
|
|
836
|
+
create: ({ projectId, projectRole, grantAdmin, targetUserId, maxUses, ttlDays, note } = {}, auditMessage) =>
|
|
837
|
+
this._request('POST', '/api/v1/invites', { auditMessage,
|
|
838
|
+
body: bodyOf({
|
|
839
|
+
'project-id': projectId, 'project-role': projectRole,
|
|
840
|
+
'grant-admin': grantAdmin, 'target-user-id': targetUserId,
|
|
841
|
+
'max-uses': maxUses, 'ttl-days': ttlDays, note,
|
|
842
|
+
}),
|
|
843
|
+
}),
|
|
844
|
+
/**
|
|
845
|
+
* Revoke an invite, killing the link immediately. Idempotent. Allowed for
|
|
846
|
+
* the creator, an admin, or a maintainer of the invite's project.
|
|
847
|
+
* @param {string} id - The invite ID
|
|
848
|
+
*/
|
|
849
|
+
revoke: (id, auditMessage) =>
|
|
850
|
+
this._request('DELETE', `/api/v1/invites/${id}`, { auditMessage }),
|
|
851
|
+
};
|
|
852
|
+
|
|
703
853
|
this.tokenLayers = {
|
|
704
854
|
/**
|
|
705
855
|
* Shift a token layer's display order.
|
|
@@ -1536,6 +1686,25 @@ class PlaidClient {
|
|
|
1536
1686
|
*/
|
|
1537
1687
|
this.query = (body, auditMessage) =>
|
|
1538
1688
|
this._request('POST', '/api/v1/query', { auditMessage, body });
|
|
1689
|
+
|
|
1690
|
+
// Logical-operation groups (audit-log grouping). There is no create: a
|
|
1691
|
+
// group row is made lazily by the first write carrying `?group-id=`
|
|
1692
|
+
// (see beginOperation). Not an audited write, so no auditMessage arg.
|
|
1693
|
+
this.operationGroups = {
|
|
1694
|
+
/**
|
|
1695
|
+
* Get a logical-operation group (its label + creator).
|
|
1696
|
+
* @param {string} id - The group id
|
|
1697
|
+
*/
|
|
1698
|
+
get: (id) =>
|
|
1699
|
+
this._request('GET', `/api/v1/operation-groups/${id}`),
|
|
1700
|
+
/**
|
|
1701
|
+
* Relabel a logical-operation group after the fact. Owner or admin only.
|
|
1702
|
+
* @param {string} id - The group id
|
|
1703
|
+
* @param {string|null} message - The new label
|
|
1704
|
+
*/
|
|
1705
|
+
update: (id, message) =>
|
|
1706
|
+
this._request('PATCH', `/api/v1/operation-groups/${id}`, { body: { message } }),
|
|
1707
|
+
};
|
|
1539
1708
|
}
|
|
1540
1709
|
|
|
1541
1710
|
// --- Core methods ---
|
|
@@ -1559,51 +1728,94 @@ class PlaidClient {
|
|
|
1559
1728
|
}
|
|
1560
1729
|
|
|
1561
1730
|
/**
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1731
|
+
* Begin a LOGICAL OPERATION: a user-meaningful action ("Merge morphemes",
|
|
1732
|
+
* "Re-transcribe") implemented as many low-level writes, possibly across
|
|
1733
|
+
* several batches and even a service round-trip. Until `endOperation()`,
|
|
1734
|
+
* every write is stamped with a client-minted `?group-id=` (and the message)
|
|
1735
|
+
* so the audit log shows the whole run as ONE expandable entry labeled
|
|
1736
|
+
* `message`, with each write's own description underneath.
|
|
1737
|
+
*
|
|
1738
|
+
* Grouping is orthogonal to batches: a batch is a transaction boundary, an
|
|
1739
|
+
* operation is an intent boundary. An operation is NOT atomic — if write 3
|
|
1740
|
+
* of 5 fails, writes 1–2 stay committed (and logged under the group). Use a
|
|
1741
|
+
* batch inside the operation for any step that must be all-or-nothing.
|
|
1742
|
+
*
|
|
1743
|
+
* Nesting flattens: a `beginOperation` while one is open is a no-op that
|
|
1744
|
+
* joins the outer operation (the outer label wins), and the matching
|
|
1745
|
+
* `endOperation` is likewise a no-op. The label is recorded on the FIRST
|
|
1746
|
+
* write, so an operation that is never ended (crash, closed tab) is still
|
|
1747
|
+
* labeled in the log.
|
|
1748
|
+
*
|
|
1749
|
+
* @param {string} message - Human label for the operation.
|
|
1750
|
+
* @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).
|
|
1751
|
+
* @returns {string} The operation's group id.
|
|
1571
1752
|
*/
|
|
1572
|
-
|
|
1573
|
-
this.
|
|
1574
|
-
|
|
1753
|
+
beginOperation(message, { id } = {}) {
|
|
1754
|
+
if (this.operationGroup) {
|
|
1755
|
+
this.operationGroup.depth += 1;
|
|
1756
|
+
return this.operationGroup.id;
|
|
1757
|
+
}
|
|
1758
|
+
this.operationGroup = {
|
|
1759
|
+
id: id || crypto.randomUUID(),
|
|
1760
|
+
message: message == null ? null : String(message),
|
|
1761
|
+
depth: 1,
|
|
1762
|
+
written: false,
|
|
1763
|
+
refined: undefined,
|
|
1764
|
+
};
|
|
1765
|
+
return this.operationGroup.id;
|
|
1575
1766
|
}
|
|
1576
1767
|
|
|
1577
|
-
/**
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1768
|
+
/**
|
|
1769
|
+
* End the current logical operation. With no argument this is purely local
|
|
1770
|
+
* (no request). Pass a refined `message` to relabel the group now that the
|
|
1771
|
+
* outcome is known (e.g. `endOperation('Merged 3 morphemes')`) — that sends
|
|
1772
|
+
* one PATCH, skipped if the operation never wrote anything. A refine from a
|
|
1773
|
+
* nested (flattened) `endOperation` is ignored; the outer label wins.
|
|
1774
|
+
* @param {string} [message] - Optional refined label.
|
|
1775
|
+
* @returns {Promise<void>}
|
|
1776
|
+
*/
|
|
1777
|
+
async endOperation(message) {
|
|
1778
|
+
const group = this.operationGroup;
|
|
1779
|
+
if (!group) return;
|
|
1780
|
+
if (group.depth > 1) {
|
|
1781
|
+
group.depth -= 1;
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
this.operationGroup = null;
|
|
1785
|
+
const refined = message !== undefined ? message : group.refined;
|
|
1786
|
+
if (refined !== undefined && group.written) {
|
|
1787
|
+
try {
|
|
1788
|
+
await this.operationGroups.update(group.id, refined);
|
|
1789
|
+
} catch (e) {
|
|
1790
|
+
// 404: the group never materialized server-side (every tagged write
|
|
1791
|
+
// failed or a batch was aborted). Nothing to relabel — not an error.
|
|
1792
|
+
if (e.status !== 404) throw e;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1581
1795
|
}
|
|
1582
1796
|
|
|
1583
1797
|
/**
|
|
1584
|
-
* Run `fn`
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1798
|
+
* Run `fn` as one logical operation (see beginOperation), ending it when
|
|
1799
|
+
* `fn` settles — including on throw. `fn` receives a `setMessage(msg)`
|
|
1800
|
+
* callback to refine the label once the outcome is known.
|
|
1587
1801
|
*
|
|
1588
|
-
* await client.
|
|
1589
|
-
* client.
|
|
1590
|
-
*
|
|
1591
|
-
* await client.withAuditMessage('Import sentence', async () => {
|
|
1592
|
-
* await client.tokens.create(...);
|
|
1593
|
-
* await client.spans.create(...);
|
|
1802
|
+
* await client.withOperation('Merge morphemes', async (setMessage) => {
|
|
1803
|
+
* await client.batched(() => { ... });
|
|
1804
|
+
* setMessage(`Merged ${n} morphemes`);
|
|
1594
1805
|
* });
|
|
1595
1806
|
*
|
|
1596
|
-
* @param {string} message -
|
|
1597
|
-
* @param {
|
|
1807
|
+
* @param {string} message - Human label for the operation.
|
|
1808
|
+
* @param {function} fn - The work to run; receives `setMessage(msg)` to refine the label once the outcome is known.
|
|
1598
1809
|
* @returns {Promise<any>} Whatever `fn` resolves to.
|
|
1599
1810
|
*/
|
|
1600
|
-
async
|
|
1601
|
-
|
|
1602
|
-
|
|
1811
|
+
async withOperation(message, fn) {
|
|
1812
|
+
this.beginOperation(message);
|
|
1813
|
+
const group = this.operationGroup;
|
|
1814
|
+
const setMessage = (msg) => { if (group.depth === 1) group.refined = msg; };
|
|
1603
1815
|
try {
|
|
1604
|
-
return await fn();
|
|
1816
|
+
return await fn(setMessage);
|
|
1605
1817
|
} finally {
|
|
1606
|
-
this.
|
|
1818
|
+
await this.endOperation();
|
|
1607
1819
|
}
|
|
1608
1820
|
}
|
|
1609
1821
|
|
|
@@ -1739,6 +1951,64 @@ class PlaidClient {
|
|
|
1739
1951
|
* @param {object} [options] - Client options forwarded to the constructor (e.g. { timeout })
|
|
1740
1952
|
* @returns {Promise<PlaidClient>} - Authenticated client instance
|
|
1741
1953
|
*/
|
|
1954
|
+
/**
|
|
1955
|
+
* Build the link to hand someone for an invite code. The server never sees
|
|
1956
|
+
* an app URL, so the app that minted the invite is the one that names it.
|
|
1957
|
+
* Both SPAs use HashRouter, so the code rides in the fragment — which also
|
|
1958
|
+
* keeps it out of server access logs.
|
|
1959
|
+
* @param {string} appUrl - Where the SPA lives, e.g. "https://plaid.example.org/igt/"
|
|
1960
|
+
* @param {string} code - The code returned by invites.create()
|
|
1961
|
+
* @returns {string}
|
|
1962
|
+
*/
|
|
1963
|
+
static inviteUrl(appUrl, code) {
|
|
1964
|
+
return `${appUrl.replace(/#.*$/, '').replace(/\/$/, '')}/#/invite/${encodeURIComponent(code)}`;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Describe an invite code, with NO authentication — this is what a signup
|
|
1969
|
+
* page calls before the redeemer has an account. Returns the kind of link
|
|
1970
|
+
* ("signup" or "password-reset"), its status ("active", "used", "expired",
|
|
1971
|
+
* "revoked"), and the project it grants access to, if any.
|
|
1972
|
+
*
|
|
1973
|
+
* Throws a 404-shaped error if the code is unknown. A known-but-dead code
|
|
1974
|
+
* resolves normally with a non-"active" status, so the page can say why.
|
|
1975
|
+
* @param {string} baseUrl - The API base URL
|
|
1976
|
+
* @param {string} code - The invite code
|
|
1977
|
+
* @param {object} [options]
|
|
1978
|
+
* @returns {Promise<{kind: string, status: string, expiresAt: string, projectName?: string, projectRole?: string, username?: string, grantAdmin: boolean}>}
|
|
1979
|
+
*/
|
|
1980
|
+
static async lookupInvite(baseUrl, code, options = {}) {
|
|
1981
|
+
return anonymousPost(baseUrl, '/api/v1/invite-codes/lookup', { code }, options);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Redeem an invite code, with NO authentication.
|
|
1986
|
+
*
|
|
1987
|
+
* For a signup invite, pass `username` and `password` to create the account;
|
|
1988
|
+
* the invite's grants are applied in the same transaction. For a password
|
|
1989
|
+
* reset link, pass `password` only. Resolves to a logged-in client, exactly
|
|
1990
|
+
* like login() — the redeemer just chose these credentials, so there is no
|
|
1991
|
+
* reason to send them to a login form to retype them.
|
|
1992
|
+
* @param {string} baseUrl - The API base URL
|
|
1993
|
+
* @param {string} code - The invite code
|
|
1994
|
+
* @param {object} credentials
|
|
1995
|
+
* @param {string} [credentials.username] - Desired username (signup only)
|
|
1996
|
+
* @param {string} credentials.password - Desired password (min 8 characters)
|
|
1997
|
+
* @param {object} [options]
|
|
1998
|
+
* @returns {Promise<{client: PlaidClient, userId: string, kind: string}>}
|
|
1999
|
+
*/
|
|
2000
|
+
static async redeemInvite(baseUrl, code, { username, password } = {}, options = {}) {
|
|
2001
|
+
const data = await anonymousPost(
|
|
2002
|
+
baseUrl, '/api/v1/invite-codes/redeem',
|
|
2003
|
+
bodyOf({ code, username, password }), options,
|
|
2004
|
+
);
|
|
2005
|
+
return {
|
|
2006
|
+
client: new PlaidClient(baseUrl.replace(/\/$/, ''), data.token || '', options),
|
|
2007
|
+
userId: data.userId,
|
|
2008
|
+
kind: data.kind,
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1742
2012
|
static async login(baseUrl, userId, password, options = {}) {
|
|
1743
2013
|
baseUrl = baseUrl.replace(/\/$/, '');
|
|
1744
2014
|
const url = `${baseUrl}/api/v1/login`;
|
|
@@ -1792,9 +2062,11 @@ export {
|
|
|
1792
2062
|
export {
|
|
1793
2063
|
PROV,
|
|
1794
2064
|
PROV_STATES,
|
|
2065
|
+
PROV_CONFIRMED,
|
|
1795
2066
|
stampInferred,
|
|
1796
2067
|
confirmedInferred,
|
|
1797
2068
|
provState,
|
|
2069
|
+
isMachine,
|
|
1798
2070
|
isProtected,
|
|
1799
2071
|
verifyOnEdit,
|
|
1800
2072
|
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/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
|
);
|