@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 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
- // Custom audit-log message (overrides the auto-generated description; may
318
- // template the endpoint's path/query/body params with {param} placeholders).
319
- auditMessage: string | null;
320
- setAuditMessage(message: string): this;
321
- clearAuditMessage(): this;
322
- withAuditMessage<T>(message: string, fn: () => Promise<T> | T): Promise<T>;
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
- /** Whether a machine writer must leave this entity alone (human or verified). */
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: { provConfirmed: true } iff machine-unverified, else null. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.2.0-alpha.0",
3
+ "version": "0.2.0-alpha.2",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -157,16 +157,25 @@ export async function makeRequest(client, method, path, options = {}) {
157
157
  }
158
158
  }
159
159
 
160
- // Custom audit-log message (overrides the auto-generated description).
161
- // Per-call `auditMessage` option wins, else the ambient `client.auditMessage`
162
- // (see setAuditMessage/withAuditMessage). Unlike document-version this has no
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
- const effectiveAuditMessage = auditMessage !== undefined ? auditMessage : client.auditMessage;
167
- if (effectiveAuditMessage && method !== 'GET') {
165
+ if (auditMessage && method !== 'GET') {
168
166
  const separator = url.includes('?') ? '&' : '?';
169
- url += `${separator}audit-message=${encodeURIComponent(effectiveAuditMessage)}`;
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
- // Ambient custom audit-log message applied to write requests; null = use
45
- // the server's auto-generated description. See setAuditMessage / withAuditMessage.
46
- this.auditMessage = null;
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: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
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: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
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: { 'include-body': includeBody, 'as-of': asOf },
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: { 'start-time': startTime, 'end-time': endTime, 'as-of': asOf },
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
- body: { body: data },
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
- * Set a custom audit-log message applied to every subsequent write request,
1563
- * OVERRIDING the server's auto-generated description (e.g. "Patch metadata
1564
- * on span X"). The message may template the endpoint's own path/query/body
1565
- * params with `{param}` placeholders, resolved server-side — e.g.
1566
- * `"Approve span {spanId}"`. Placeholder names are case/separator-insensitive
1567
- * (`{spanId}` == `{span-id}` == `{span_id}`), so use the camelCase names this
1568
- * client uses. Applies to GET-less requests
1569
- * only, including every operation queued in a batch.
1570
- * @param {string} message - The custom message (template).
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
- setAuditMessage(message) {
1573
- this.auditMessage = message;
1574
- return this;
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
- /** Clear the ambient custom audit-log message (revert to auto-generated). */
1578
- clearAuditMessage() {
1579
- this.auditMessage = null;
1580
- return this;
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` with a custom audit-log message in effect, restoring the previous
1585
- * message afterward (supports nesting). Use it to scope ONE call (per-call
1586
- * precision) or MANY (a logical unit):
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.withAuditMessage('Import sentence', async () => {
1592
- * await client.tokens.create(...);
1593
- * await client.spans.create(...);
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 - The custom message (template).
1597
- * @param {() => (Promise<any>|any)} fn - The work to run with the message set.
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 withAuditMessage(message, fn) {
1601
- const prev = this.auditMessage;
1602
- this.auditMessage = message;
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.auditMessage = prev;
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) => provState(metadata) !== PROV_STATES.MACHINE;
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): { provConfirmed: true } when the entity is
117
- * machine-made and unverified, else null (nothing to do).
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}`;
@@ -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(data === undefined ? null : transformRequest(data)),
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, transformResponse(parsedData));
146
+ const shouldStop = onEvent(eventType, eventPayload(eventType, parsedData));
128
147
  if (shouldStop === true) {
129
148
  sseConnection.close();
130
149
  return;