@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 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;
@@ -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
- /** Whether a machine writer must leave this entity alone (human or verified). */
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: { provConfirmed: true } iff machine-unverified, else null. */
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
@@ -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.1",
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
@@ -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
- // 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;
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
- * 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).
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
- setAuditMessage(message) {
1573
- this.auditMessage = message;
1574
- return this;
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
- /** Clear the ambient custom audit-log message (revert to auto-generated). */
1578
- clearAuditMessage() {
1579
- this.auditMessage = null;
1580
- return this;
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` 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):
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.withAuditMessage('Approve span {spanId}', () =>
1589
- * client.spans.update(spanId, ...));
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 - The custom message (template).
1597
- * @param {() => (Promise<any>|any)} fn - The work to run with the message set.
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 withAuditMessage(message, fn) {
1601
- const prev = this.auditMessage;
1602
- this.auditMessage = message;
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.auditMessage = prev;
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) => 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}`;
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
  );