@oxyhq/core 3.18.1 → 4.0.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.
Files changed (52) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.js +3 -2
  3. package/dist/cjs/i18n/locales/en-US.json +391 -17
  4. package/dist/cjs/i18n/locales/es-ES.json +391 -17
  5. package/dist/cjs/i18n/locales/locales/en-US.json +391 -17
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +391 -17
  7. package/dist/cjs/mixins/OxyServices.accounts.js +480 -0
  8. package/dist/cjs/mixins/OxyServices.connectedApps.js +73 -0
  9. package/dist/cjs/mixins/OxyServices.utility.js +3 -2
  10. package/dist/cjs/mixins/index.js +9 -6
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/OxyServices.js +3 -2
  13. package/dist/esm/i18n/locales/en-US.json +391 -17
  14. package/dist/esm/i18n/locales/es-ES.json +391 -17
  15. package/dist/esm/i18n/locales/locales/en-US.json +391 -17
  16. package/dist/esm/i18n/locales/locales/es-ES.json +391 -17
  17. package/dist/esm/mixins/OxyServices.accounts.js +477 -0
  18. package/dist/esm/mixins/OxyServices.connectedApps.js +70 -0
  19. package/dist/esm/mixins/OxyServices.utility.js +3 -2
  20. package/dist/esm/mixins/index.js +9 -6
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/OxyServices.d.ts +3 -2
  23. package/dist/types/index.d.ts +2 -3
  24. package/dist/types/mixins/OxyServices.accounts.d.ts +642 -0
  25. package/dist/types/mixins/OxyServices.auth.d.ts +1 -1
  26. package/dist/types/mixins/OxyServices.connectedApps.d.ts +168 -0
  27. package/dist/types/mixins/OxyServices.utility.d.ts +6 -3
  28. package/dist/types/mixins/index.d.ts +3 -4
  29. package/package.json +1 -1
  30. package/src/OxyServices.ts +3 -2
  31. package/src/i18n/locales/en-US.json +391 -17
  32. package/src/i18n/locales/es-ES.json +391 -17
  33. package/src/index.ts +33 -34
  34. package/src/mixins/OxyServices.accounts.ts +1079 -0
  35. package/src/mixins/OxyServices.auth.ts +1 -1
  36. package/src/mixins/OxyServices.connectedApps.ts +165 -0
  37. package/src/mixins/OxyServices.utility.ts +7 -4
  38. package/src/mixins/__tests__/accounts.test.ts +667 -0
  39. package/src/mixins/__tests__/connectedApps.test.ts +1 -1
  40. package/src/mixins/index.ts +11 -9
  41. package/dist/cjs/mixins/OxyServices.applications.js +0 -350
  42. package/dist/cjs/mixins/OxyServices.managedAccounts.js +0 -143
  43. package/dist/cjs/mixins/OxyServices.workspaces.js +0 -181
  44. package/dist/esm/mixins/OxyServices.applications.js +0 -347
  45. package/dist/esm/mixins/OxyServices.managedAccounts.js +0 -140
  46. package/dist/esm/mixins/OxyServices.workspaces.js +0 -178
  47. package/dist/types/mixins/OxyServices.applications.d.ts +0 -496
  48. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +0 -145
  49. package/dist/types/mixins/OxyServices.workspaces.d.ts +0 -219
  50. package/src/mixins/OxyServices.applications.ts +0 -773
  51. package/src/mixins/OxyServices.managedAccounts.ts +0 -173
  52. package/src/mixins/OxyServices.workspaces.ts +0 -351
@@ -0,0 +1,1079 @@
1
+ /**
2
+ * Accounts Methods Mixin
3
+ *
4
+ * The single client surface for the unified Oxy **account graph** (`/accounts`)
5
+ * and the **applications** owned within it (`/applications`).
6
+ *
7
+ * An account is a relational, tree-structured principal (the `User` document
8
+ * generalised): a `personal` account is a human login at the root of its tree;
9
+ * `organization` / `project` / `bot` accounts are non-login principals operated
10
+ * through membership. Accounts form a tree (`parentAccountId`), own
11
+ * applications/bots, and expose a single membership model (`AccountMember`) with
12
+ * a unified role set and an explicit-but-inheritable cascade down the subtree.
13
+ *
14
+ * This mixin is the clean-cut replacement for the former `managedAccounts`,
15
+ * `workspaces`, and `applications` (account-management) mixins. Applications are
16
+ * now owned by an account (`Application.ownerAccountId`) and their access derives
17
+ * from the caller's `AccountMember` on that owning account — there is no separate
18
+ * application-membership surface. The OAuth-consent surface a user sees for
19
+ * THIRD-PARTY apps they authorized (`getPublicApplication`, `listConnectedApps`,
20
+ * `revokeAppGrant`) is unrelated to account ownership and lives in
21
+ * `OxyServices.connectedApps.ts`.
22
+ *
23
+ * Reference accounts by their Mongo `_id` (`accountId`, the underlying
24
+ * `User._id`), applications by their `_id` (`applicationId`), members by their
25
+ * member `_id`, and credentials by their `credentialId`. Never by name, slug, or
26
+ * handle.
27
+ *
28
+ * NOTE: acting-as (delegated identity) is NOT part of this mixin — `setActingAs`
29
+ * / `getActingAs` live on `OxyServices.base` and `verifyActingAs` on the utility
30
+ * mixin (it verifies against `GET /accounts/verify-acting-as`).
31
+ */
32
+ import type { User } from '../models/interfaces';
33
+ import type { OxyServicesBase } from '../OxyServices.base';
34
+ import { CACHE_TIMES } from './mixinHelpers';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Account graph types
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Account classification, orthogonal to the federation `type`
42
+ * (`local|federated|agent|automated`). `personal` accounts have a direct login;
43
+ * `organization` / `project` / `bot` accounts are operated via `AccountMember`
44
+ * and have no direct login.
45
+ */
46
+ export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
47
+
48
+ /**
49
+ * The calling user's relationship to an account node, as resolved by the API:
50
+ * - `self` — the caller's own personal (root) account.
51
+ * - `owner` — an account the caller owns (e.g. an org/project/bot they created).
52
+ * - `member` — an account shared with the caller via membership (including
53
+ * external organisations).
54
+ */
55
+ export type AccountRelationship = 'self' | 'owner' | 'member';
56
+
57
+ /** Role a member holds within an account. The unified account role set. */
58
+ export type AccountRole = 'owner' | 'admin' | 'editor' | 'developer' | 'billing' | 'viewer';
59
+
60
+ /** Membership lifecycle status. */
61
+ export type AccountMemberStatus = 'active' | 'invited' | 'removed';
62
+
63
+ /**
64
+ * Origin of a resolved membership. `direct` is a membership row on the account
65
+ * itself; `inherited` is resolved from the nearest ancestor account whose
66
+ * membership row has `inherit: true` (role inheritance cascades down the tree).
67
+ */
68
+ export type AccountMemberSource = 'direct' | 'inherited';
69
+
70
+ /**
71
+ * Client-facing AccountMember shape. `permissions` is derived from `role` on the
72
+ * server at write time.
73
+ */
74
+ export interface AccountMember {
75
+ _id: string;
76
+ /** The account this membership grants access to (account `_id`). */
77
+ accountId: string;
78
+ /** The member's personal-account `User._id`. */
79
+ memberUserId: string;
80
+ role: AccountRole;
81
+ permissions: string[];
82
+ /**
83
+ * Whether this membership cascades to the account's subtree. `true` (default)
84
+ * lets descendants inherit this role unless a nearer row overrides it; `false`
85
+ * opts this row out of inheritance (it applies to this account only).
86
+ */
87
+ inherit: boolean;
88
+ status: AccountMemberStatus;
89
+ /**
90
+ * Origin of the membership when the API resolves an effective role. Present on
91
+ * a resolved `callerMembership` to indicate whether the caller's access is
92
+ * `direct` on the account or `inherited` from an ancestor. Absent on plain
93
+ * member-list rows (which are always direct rows on the account).
94
+ */
95
+ source?: AccountMemberSource;
96
+ invitedByUserId?: string | null;
97
+ joinedAt?: string | null;
98
+ createdAt: string;
99
+ updatedAt: string;
100
+ }
101
+
102
+ /**
103
+ * A node in the account graph as returned by the `/accounts` API. `account` is
104
+ * the underlying generalised `User` document; `relationship` and
105
+ * `callerMembership` describe the caller's access. On a flat list every node
106
+ * carries `parentAccountId`; with `tree:true`, `children` is populated and
107
+ * `childCount` reflects the number of direct children.
108
+ */
109
+ export interface AccountNode {
110
+ /** The account's Mongo `_id` (the underlying `User._id`). */
111
+ accountId: string;
112
+ kind: AccountKind;
113
+ /** Parent account `_id`, or `null` for a root (personal) account. */
114
+ parentAccountId: string | null;
115
+ /** The generalised `User` document backing this account. */
116
+ account: User;
117
+ relationship: AccountRelationship;
118
+ /**
119
+ * The caller's effective membership in this account (direct or inherited), or
120
+ * `null` when the caller has no membership (e.g. their own `self` root, where
121
+ * ownership is implicit). Use `callerMembership.permissions` to gate UI.
122
+ */
123
+ callerMembership: AccountMember | null;
124
+ /** Number of direct child accounts (present when the API computes it). */
125
+ childCount?: number;
126
+ /** Direct children, populated when the list is requested with `tree:true`. */
127
+ children?: AccountNode[];
128
+ }
129
+
130
+ /** Options accepted by `listAccounts`. */
131
+ export interface ListAccountsOptions {
132
+ /**
133
+ * When `true`, request the nested tree representation: each returned node has
134
+ * its `children` populated instead of a flat list keyed by `parentAccountId`.
135
+ */
136
+ tree?: boolean;
137
+ }
138
+
139
+ /** Input accepted by `createAccount`. */
140
+ export interface CreateAccountInput {
141
+ /** Classification of the new account. `personal` accounts are not created here. */
142
+ kind: AccountKind;
143
+ /**
144
+ * Parent account `_id` to nest the new account under. Omitted → the API roots
145
+ * it under the caller's personal account.
146
+ */
147
+ parentAccountId?: string;
148
+ /** Unique handle for the account (shares the `User.username` unique index). */
149
+ username: string;
150
+ name?: { first?: string; last?: string };
151
+ bio?: string;
152
+ avatar?: string;
153
+ }
154
+
155
+ /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
156
+ export interface UpdateAccountInput {
157
+ username?: string;
158
+ name?: { first?: string; last?: string };
159
+ bio?: string | null;
160
+ avatar?: string | null;
161
+ }
162
+
163
+ /** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
164
+ export interface InviteAccountMemberInput {
165
+ /**
166
+ * The username or email of the user to invite. Resolved to a personal account
167
+ * server-side; an unknown value yields a 404 "User not found".
168
+ */
169
+ usernameOrEmail: string;
170
+ role: Exclude<AccountRole, 'owner'>;
171
+ }
172
+
173
+ /** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
174
+ export interface UpdateAccountMemberInput {
175
+ role: Exclude<AccountRole, 'owner'>;
176
+ }
177
+
178
+ /** Input accepted by `transferAccountOwnership`. */
179
+ export interface TransferAccountOwnershipInput {
180
+ userId: string;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Bot (account) service-credential types
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /** Credential kind. Account (bot) credentials are always `service` tokens. */
188
+ export type AccountCredentialType = 'service';
189
+
190
+ /** Deployment environment a bot credential is scoped to. */
191
+ export type AccountCredentialEnvironment = 'development' | 'staging' | 'production';
192
+
193
+ /** Bot credential lifecycle status. */
194
+ export type AccountCredentialStatus = 'active' | 'deprecated' | 'revoked';
195
+
196
+ /** Input accepted by `createAccountCredential`. Credential `type` is always `service`. */
197
+ export interface CreateAccountCredentialInput {
198
+ name: string;
199
+ environment: AccountCredentialEnvironment;
200
+ scopes?: string[];
201
+ }
202
+
203
+ /**
204
+ * Client-facing AccountCredential shape (a bot account's service token). The raw
205
+ * secret is NEVER part of this shape — it is returned exactly once, separately,
206
+ * at creation/rotation.
207
+ */
208
+ export interface AccountCredential {
209
+ _id: string;
210
+ /** The bot account this credential authenticates as (account `_id`). */
211
+ accountId: string;
212
+ name: string;
213
+ publicKey: string;
214
+ type: AccountCredentialType;
215
+ environment: AccountCredentialEnvironment;
216
+ scopes: string[];
217
+ status: AccountCredentialStatus;
218
+ lastUsedAt?: string;
219
+ expiresAt?: string;
220
+ /**
221
+ * Audit link to the credential this one was rotated FROM. Populated on
222
+ * credentials created via rotation; absent on original credentials.
223
+ */
224
+ rotatedFromCredentialId?: string;
225
+ createdByUserId: string;
226
+ createdAt: string;
227
+ updatedAt: string;
228
+ }
229
+
230
+ /** Result of creating a bot credential — `secret` is returned ONCE. */
231
+ export interface AccountCredentialWithSecret {
232
+ credential: AccountCredential;
233
+ secret: string;
234
+ }
235
+
236
+ /**
237
+ * Result of rotating a bot credential. Extends the create result with audit
238
+ * fields: the new plaintext `secret` is returned ONCE, plus `rotatedFrom` (the
239
+ * previous credential's `credentialId`) and `graceExpiresAt` (ISO string marking
240
+ * when the old credential stops being honoured during the rotation grace window).
241
+ */
242
+ export interface RotateAccountCredentialResult extends AccountCredentialWithSecret {
243
+ /** The previous credential's `credentialId` that this rotation supersedes. */
244
+ rotatedFrom: string;
245
+ /** ISO timestamp at which the rotated-from credential's grace window ends. */
246
+ graceExpiresAt: string;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Application (owned by an account) types
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Application classification. Set only by Oxy platform staff — never editable
255
+ * through the normal member-facing update path.
256
+ */
257
+ export type ApplicationType = 'first_party' | 'third_party' | 'internal' | 'system';
258
+
259
+ /** Lifecycle status of an application. */
260
+ export type ApplicationStatus = 'active' | 'suspended' | 'deleted' | 'pending_review';
261
+
262
+ /** OAuth credential kind. `service` credentials mint service tokens. */
263
+ export type ApplicationCredentialType = 'public' | 'confidential' | 'service';
264
+
265
+ /** Deployment environment an application credential is scoped to. */
266
+ export type ApplicationEnvironment = 'development' | 'staging' | 'production';
267
+
268
+ /** Application credential lifecycle status. */
269
+ export type ApplicationCredentialStatus = 'active' | 'deprecated' | 'revoked';
270
+
271
+ /**
272
+ * Client-facing Application shape returned by the `/applications` API. An
273
+ * application is the OAuth client; it is OWNED by an account
274
+ * (`ownerAccountId`), and the caller's access derives from their `AccountMember`
275
+ * on that owning account (with inheritance).
276
+ */
277
+ export interface Application {
278
+ _id: string;
279
+ name: string;
280
+ description?: string;
281
+ websiteUrl?: string;
282
+ icon?: string;
283
+ type: ApplicationType;
284
+ status: ApplicationStatus;
285
+ isOfficial: boolean;
286
+ isInternal: boolean;
287
+ capabilities: string[];
288
+ redirectUris: string[];
289
+ scopes: string[];
290
+ webhookUrl?: string;
291
+ devWebhookUrl?: string;
292
+ createdByUserId: string;
293
+ /**
294
+ * The account that owns this application (account `_id`). Access to the
295
+ * application derives from the caller's `AccountMember` on this account, with
296
+ * inheritance up the account tree.
297
+ */
298
+ ownerAccountId: string;
299
+ createdAt: string;
300
+ updatedAt: string;
301
+ /**
302
+ * The caller's effective membership in the OWNING account (direct or
303
+ * inherited), embedded by the API on list/detail responses, or `null` when the
304
+ * caller has no membership. Use `callerMembership.permissions` to gate UI.
305
+ */
306
+ callerMembership?: AccountMember | null;
307
+ }
308
+
309
+ /**
310
+ * Client-facing ApplicationCredential shape (an application's OAuth client
311
+ * credentials). The raw secret is NEVER part of this shape — it is returned
312
+ * exactly once, separately, at creation/rotation.
313
+ */
314
+ export interface ApplicationCredential {
315
+ _id: string;
316
+ applicationId: string;
317
+ name: string;
318
+ publicKey: string;
319
+ type: ApplicationCredentialType;
320
+ environment: ApplicationEnvironment;
321
+ scopes: string[];
322
+ status: ApplicationCredentialStatus;
323
+ lastUsedAt?: string;
324
+ expiresAt?: string;
325
+ /**
326
+ * Audit link to the credential this one was rotated FROM. Populated on
327
+ * credentials created via rotation; absent on original credentials.
328
+ */
329
+ rotatedFromCredentialId?: string;
330
+ createdByUserId: string;
331
+ createdAt: string;
332
+ updatedAt: string;
333
+ }
334
+
335
+ /** Input accepted by `createApp`. Staff-only fields are not settable here. */
336
+ export interface CreateApplicationInput {
337
+ name: string;
338
+ description?: string;
339
+ websiteUrl?: string;
340
+ icon?: string;
341
+ redirectUris?: string[];
342
+ scopes?: string[];
343
+ /**
344
+ * Owning account `_id`. Omitted → the API defaults to the caller's personal
345
+ * account.
346
+ */
347
+ ownerAccountId?: string;
348
+ }
349
+
350
+ /** Input accepted by `updateApp`. Staff-only fields are not settable here. */
351
+ export interface UpdateApplicationInput {
352
+ name?: string;
353
+ description?: string;
354
+ websiteUrl?: string;
355
+ icon?: string;
356
+ redirectUris?: string[];
357
+ scopes?: string[];
358
+ webhookUrl?: string;
359
+ devWebhookUrl?: string;
360
+ status?: ApplicationStatus;
361
+ }
362
+
363
+ /** Input accepted by `createAppCredential`. */
364
+ export interface CreateApplicationCredentialInput {
365
+ name: string;
366
+ type: ApplicationCredentialType;
367
+ environment: ApplicationEnvironment;
368
+ scopes?: string[];
369
+ }
370
+
371
+ /** Result of creating an application credential — `secret` is returned ONCE. */
372
+ export interface ApplicationCredentialWithSecret {
373
+ credential: ApplicationCredential;
374
+ secret: string;
375
+ }
376
+
377
+ /**
378
+ * Result of rotating an application credential. Extends the create result with
379
+ * audit fields: the new plaintext `secret` is returned ONCE, plus `rotatedFrom`
380
+ * (the previous credential's `credentialId`) and `graceExpiresAt` (ISO string
381
+ * marking when the old credential stops being honoured during the grace window).
382
+ */
383
+ export interface RotateApplicationCredentialResult extends ApplicationCredentialWithSecret {
384
+ /** The previous credential's `credentialId` that this rotation supersedes. */
385
+ rotatedFrom: string;
386
+ /** ISO timestamp at which the rotated-from credential's grace window ends. */
387
+ graceExpiresAt: string;
388
+ }
389
+
390
+ /** Time window for application usage statistics. */
391
+ export type ApplicationUsagePeriod = '24h' | '7d' | '30d' | '90d';
392
+
393
+ /** Aggregate totals for an application over the requested period. */
394
+ export interface ApplicationUsageSummary {
395
+ totalRequests: number;
396
+ totalTokens: number;
397
+ totalCredits: number;
398
+ avgResponseTime: number;
399
+ successfulRequests: number;
400
+ errorRequests: number;
401
+ }
402
+
403
+ /** Per-day usage bucket. `_id` is the day key (e.g. `YYYY-MM-DD`). */
404
+ export interface ApplicationUsageByDay {
405
+ _id: string;
406
+ requests: number;
407
+ tokens: number;
408
+ credits: number;
409
+ }
410
+
411
+ /** Per-endpoint usage bucket. `_id` is the endpoint identifier. */
412
+ export interface ApplicationUsageByEndpoint {
413
+ _id: string;
414
+ requests: number;
415
+ tokens: number;
416
+ }
417
+
418
+ /** Usage statistics for an application over a period. */
419
+ export interface ApplicationUsageStats {
420
+ summary: ApplicationUsageSummary;
421
+ byDay: ApplicationUsageByDay[];
422
+ byEndpoint: ApplicationUsageByEndpoint[];
423
+ }
424
+
425
+ /** Result of an archive/remove/revoke/transfer/delete operation. */
426
+ export interface AccountSuccessResult {
427
+ success: boolean;
428
+ }
429
+
430
+ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
431
+ return class extends Base {
432
+ constructor(...args: any[]) {
433
+ super(...(args as [any]));
434
+ }
435
+
436
+ // =========================================================================
437
+ // Accounts
438
+ // =========================================================================
439
+
440
+ /**
441
+ * List the accounts the caller can access: their own personal (root)
442
+ * account, accounts they own, and accounts shared with them (including
443
+ * external organisations), plus the reachable subtree of each.
444
+ *
445
+ * @param opts - `{ tree: true }` requests the nested tree representation
446
+ * (`children` populated) instead of a flat list. The flag is appended to
447
+ * the path as `?tree=true`, so the response cache keys on it automatically —
448
+ * the flat and tree variants never collide.
449
+ */
450
+ async listAccounts(opts?: ListAccountsOptions): Promise<AccountNode[]> {
451
+ try {
452
+ const path = opts?.tree ? '/accounts?tree=true' : '/accounts';
453
+ const res = await this.makeRequest<{ accounts?: AccountNode[] }>(
454
+ 'GET',
455
+ path,
456
+ undefined,
457
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
458
+ );
459
+ return res.accounts ?? [];
460
+ } catch (error) {
461
+ throw this.handleError(error);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Fetch a single account node by id.
467
+ * @param accountId - The account's Mongo `_id`.
468
+ */
469
+ async getAccount(accountId: string): Promise<AccountNode> {
470
+ try {
471
+ const res = await this.makeRequest<{ account: AccountNode }>(
472
+ 'GET',
473
+ `/accounts/${encodeURIComponent(accountId)}`,
474
+ undefined,
475
+ { cache: true, cacheTTL: CACHE_TIMES.LONG },
476
+ );
477
+ return res.account;
478
+ } catch (error) {
479
+ throw this.handleError(error);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Create a new (non-personal) account. The caller becomes its `owner`.
485
+ * @param data - Account configuration: kind, optional parent, and profile.
486
+ */
487
+ async createAccount(data: CreateAccountInput): Promise<AccountNode> {
488
+ try {
489
+ const res = await this.makeRequest<{ account: AccountNode }>(
490
+ 'POST',
491
+ '/accounts',
492
+ data,
493
+ { cache: false },
494
+ );
495
+ // A new account changes the accessible forest — bust every cached list
496
+ // (flat + tree) so it appears on the next `listAccounts()` read.
497
+ this._invalidateAccountLists();
498
+ return res.account;
499
+ } catch (error) {
500
+ throw this.handleError(error);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Update an account's mutable profile fields. Tree placement changes
506
+ * (reparenting) go through the dedicated move endpoint, not here.
507
+ * @param accountId - The account's Mongo `_id`.
508
+ * @param data - Subset of updatable profile fields.
509
+ */
510
+ async updateAccount(
511
+ accountId: string,
512
+ data: UpdateAccountInput,
513
+ ): Promise<AccountNode> {
514
+ try {
515
+ const res = await this.makeRequest<{ account: AccountNode }>(
516
+ 'PATCH',
517
+ `/accounts/${encodeURIComponent(accountId)}`,
518
+ data,
519
+ { cache: false },
520
+ );
521
+ // Bust the cached detail and every list (which embeds account profile
522
+ // data) so neither serves the pre-update snapshot.
523
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
524
+ this._invalidateAccountLists();
525
+ return res.account;
526
+ } catch (error) {
527
+ throw this.handleError(error);
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Archive an account (soft delete). Named `archiveAccount` — NOT
533
+ * `deleteAccount`, which is reserved for the GDPR self-deletion flow on the
534
+ * user mixin (`OxyServices.user.ts`).
535
+ * @param accountId - The account's Mongo `_id`.
536
+ */
537
+ async archiveAccount(accountId: string): Promise<AccountSuccessResult> {
538
+ try {
539
+ const result = await this.makeRequest<AccountSuccessResult>(
540
+ 'DELETE',
541
+ `/accounts/${encodeURIComponent(accountId)}`,
542
+ undefined,
543
+ { cache: false },
544
+ );
545
+ // Bust every cached representation of the archived account.
546
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
547
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
548
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
549
+ this._invalidateAccountLists();
550
+ return result;
551
+ } catch (error) {
552
+ throw this.handleError(error);
553
+ }
554
+ }
555
+
556
+ /**
557
+ * List the direct child accounts of an account.
558
+ * @param accountId - The parent account's Mongo `_id`.
559
+ */
560
+ async listChildAccounts(accountId: string): Promise<AccountNode[]> {
561
+ try {
562
+ const res = await this.makeRequest<{ accounts?: AccountNode[] }>(
563
+ 'GET',
564
+ `/accounts/${encodeURIComponent(accountId)}/children`,
565
+ undefined,
566
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
567
+ );
568
+ return res.accounts ?? [];
569
+ } catch (error) {
570
+ throw this.handleError(error);
571
+ }
572
+ }
573
+
574
+ // =========================================================================
575
+ // Account members
576
+ // =========================================================================
577
+
578
+ /**
579
+ * List members of an account (direct membership rows on the account).
580
+ * @param accountId - The account's Mongo `_id`.
581
+ */
582
+ async listAccountMembers(accountId: string): Promise<AccountMember[]> {
583
+ try {
584
+ const res = await this.makeRequest<{ members?: AccountMember[] }>(
585
+ 'GET',
586
+ `/accounts/${encodeURIComponent(accountId)}/members`,
587
+ undefined,
588
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
589
+ );
590
+ return res.members ?? [];
591
+ } catch (error) {
592
+ throw this.handleError(error);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Add a member to an account.
598
+ * @param accountId - The account's Mongo `_id`.
599
+ * @param data - Target user's username or email and role (never `owner`).
600
+ * The server resolves `usernameOrEmail` to a personal account; an unknown
601
+ * value yields a 404 "User not found".
602
+ */
603
+ async inviteAccountMember(
604
+ accountId: string,
605
+ data: InviteAccountMemberInput,
606
+ ): Promise<AccountMember> {
607
+ try {
608
+ const res = await this.makeRequest<{ member: AccountMember }>(
609
+ 'POST',
610
+ `/accounts/${encodeURIComponent(accountId)}/members`,
611
+ data,
612
+ { cache: false },
613
+ );
614
+ this._invalidateAccountMembership(accountId);
615
+ return res.member;
616
+ } catch (error) {
617
+ throw this.handleError(error);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Change a member's role.
623
+ * @param accountId - The account's Mongo `_id`.
624
+ * @param memberId - The member's Mongo `_id`.
625
+ * @param data - New role (never `owner`).
626
+ */
627
+ async updateAccountMember(
628
+ accountId: string,
629
+ memberId: string,
630
+ data: UpdateAccountMemberInput,
631
+ ): Promise<AccountMember> {
632
+ try {
633
+ const res = await this.makeRequest<{ member: AccountMember }>(
634
+ 'PATCH',
635
+ `/accounts/${encodeURIComponent(accountId)}/members/${encodeURIComponent(memberId)}`,
636
+ data,
637
+ { cache: false },
638
+ );
639
+ this._invalidateAccountMembership(accountId);
640
+ return res.member;
641
+ } catch (error) {
642
+ throw this.handleError(error);
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Remove a member from an account.
648
+ * @param accountId - The account's Mongo `_id`.
649
+ * @param memberId - The member's Mongo `_id`.
650
+ */
651
+ async removeAccountMember(
652
+ accountId: string,
653
+ memberId: string,
654
+ ): Promise<AccountSuccessResult> {
655
+ try {
656
+ const result = await this.makeRequest<AccountSuccessResult>(
657
+ 'DELETE',
658
+ `/accounts/${encodeURIComponent(accountId)}/members/${encodeURIComponent(memberId)}`,
659
+ undefined,
660
+ { cache: false },
661
+ );
662
+ this._invalidateAccountMembership(accountId);
663
+ return result;
664
+ } catch (error) {
665
+ throw this.handleError(error);
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Transfer ownership of an account to another member (owner only).
671
+ * @param accountId - The account's Mongo `_id`.
672
+ * @param data - Target user id.
673
+ */
674
+ async transferAccountOwnership(
675
+ accountId: string,
676
+ data: TransferAccountOwnershipInput,
677
+ ): Promise<AccountSuccessResult> {
678
+ try {
679
+ const result = await this.makeRequest<AccountSuccessResult>(
680
+ 'POST',
681
+ `/accounts/${encodeURIComponent(accountId)}/transfer-ownership`,
682
+ data,
683
+ { cache: false },
684
+ );
685
+ // Ownership change alters roles in the member list AND the detail, and
686
+ // can change which accounts the caller "owns" in the list view.
687
+ this._invalidateAccountMembership(accountId);
688
+ this._invalidateAccountLists();
689
+ return result;
690
+ } catch (error) {
691
+ throw this.handleError(error);
692
+ }
693
+ }
694
+
695
+ // =========================================================================
696
+ // Bot (account) service credentials — /accounts/:id/credentials
697
+ // =========================================================================
698
+
699
+ /**
700
+ * List a bot account's service credentials. The response NEVER includes
701
+ * secrets.
702
+ * @param accountId - The account's Mongo `_id`.
703
+ */
704
+ async listAccountCredentials(accountId: string): Promise<AccountCredential[]> {
705
+ try {
706
+ const res = await this.makeRequest<{ credentials?: AccountCredential[] }>(
707
+ 'GET',
708
+ `/accounts/${encodeURIComponent(accountId)}/credentials`,
709
+ undefined,
710
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
711
+ );
712
+ return res.credentials ?? [];
713
+ } catch (error) {
714
+ throw this.handleError(error);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Create a service credential for a bot account. The plaintext `secret` is
720
+ * returned exactly ONCE; the server stores only a hash and will never return
721
+ * it again.
722
+ * @param accountId - The account's Mongo `_id`.
723
+ * @param data - Credential configuration (`type` is always `service`).
724
+ */
725
+ async createAccountCredential(
726
+ accountId: string,
727
+ data: CreateAccountCredentialInput,
728
+ ): Promise<AccountCredentialWithSecret> {
729
+ try {
730
+ const result = await this.makeRequest<AccountCredentialWithSecret>(
731
+ 'POST',
732
+ `/accounts/${encodeURIComponent(accountId)}/credentials`,
733
+ data,
734
+ { cache: false },
735
+ );
736
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
737
+ return result;
738
+ } catch (error) {
739
+ throw this.handleError(error);
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Rotate a bot credential's secret. The new plaintext `secret` is returned
745
+ * exactly ONCE, along with audit fields: `rotatedFrom` (the previous
746
+ * credentialId) and `graceExpiresAt` (ISO string for the grace window during
747
+ * which the old credential is still honoured).
748
+ * @param accountId - The account's Mongo `_id`.
749
+ * @param credentialId - The credential's Mongo `_id`.
750
+ */
751
+ async rotateAccountCredential(
752
+ accountId: string,
753
+ credentialId: string,
754
+ ): Promise<RotateAccountCredentialResult> {
755
+ try {
756
+ const result = await this.makeRequest<RotateAccountCredentialResult>(
757
+ 'POST',
758
+ `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
759
+ undefined,
760
+ { cache: false },
761
+ );
762
+ // Rotation changes credential status/audit fields surfaced by the list.
763
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
764
+ return result;
765
+ } catch (error) {
766
+ throw this.handleError(error);
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Revoke a bot credential (`status='revoked'`). Revoked credentials can no
772
+ * longer authenticate.
773
+ * @param accountId - The account's Mongo `_id`.
774
+ * @param credentialId - The credential's Mongo `_id`.
775
+ */
776
+ async revokeAccountCredential(
777
+ accountId: string,
778
+ credentialId: string,
779
+ ): Promise<AccountSuccessResult> {
780
+ try {
781
+ const result = await this.makeRequest<AccountSuccessResult>(
782
+ 'DELETE',
783
+ `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}`,
784
+ undefined,
785
+ { cache: false },
786
+ );
787
+ // Revocation flips the credential's status in the cached list.
788
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
789
+ return result;
790
+ } catch (error) {
791
+ throw this.handleError(error);
792
+ }
793
+ }
794
+
795
+ // =========================================================================
796
+ // Applications owned by an account — /applications
797
+ // =========================================================================
798
+
799
+ /**
800
+ * List the applications owned by an account. Backed by
801
+ * `GET /applications?ownerAccountId=<id>`.
802
+ * @param accountId - The owning account's Mongo `_id`.
803
+ */
804
+ async listAccountApps(accountId: string): Promise<Application[]> {
805
+ try {
806
+ const res = await this.makeRequest<{ applications?: Application[] }>(
807
+ 'GET',
808
+ `/applications?ownerAccountId=${encodeURIComponent(accountId)}`,
809
+ undefined,
810
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
811
+ );
812
+ return res.applications ?? [];
813
+ } catch (error) {
814
+ throw this.handleError(error);
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Create a new application owned by an account.
820
+ * @param data - Application configuration. `ownerAccountId` defaults to the
821
+ * caller's personal account when omitted. Staff-only fields are ignored.
822
+ */
823
+ async createApp(data: CreateApplicationInput): Promise<Application> {
824
+ try {
825
+ const res = await this.makeRequest<{ application: Application }>(
826
+ 'POST',
827
+ '/applications',
828
+ data,
829
+ { cache: false },
830
+ );
831
+ // Bust every cached application list (per owning account) so the new app
832
+ // appears on the next `listAccountApps()` read.
833
+ this._invalidateAppLists();
834
+ return res.application;
835
+ } catch (error) {
836
+ throw this.handleError(error);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Fetch a single application by id.
842
+ * @param applicationId - The application's Mongo `_id`.
843
+ */
844
+ async getApp(applicationId: string): Promise<Application> {
845
+ try {
846
+ const res = await this.makeRequest<{ application: Application }>(
847
+ 'GET',
848
+ `/applications/${encodeURIComponent(applicationId)}`,
849
+ undefined,
850
+ { cache: true, cacheTTL: CACHE_TIMES.LONG },
851
+ );
852
+ return res.application;
853
+ } catch (error) {
854
+ throw this.handleError(error);
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Update an application's mutable fields.
860
+ * @param applicationId - The application's Mongo `_id`.
861
+ * @param data - Subset of updatable fields. Staff-only fields are ignored.
862
+ */
863
+ async updateApp(
864
+ applicationId: string,
865
+ data: UpdateApplicationInput,
866
+ ): Promise<Application> {
867
+ try {
868
+ const res = await this.makeRequest<{ application: Application }>(
869
+ 'PATCH',
870
+ `/applications/${encodeURIComponent(applicationId)}`,
871
+ data,
872
+ { cache: false },
873
+ );
874
+ // Bust the cached detail and every list (which embeds application fields).
875
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}`);
876
+ this._invalidateAppLists();
877
+ return res.application;
878
+ } catch (error) {
879
+ throw this.handleError(error);
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Soft-delete an application.
885
+ * @param applicationId - The application's Mongo `_id`.
886
+ */
887
+ async deleteApp(applicationId: string): Promise<AccountSuccessResult> {
888
+ try {
889
+ const result = await this.makeRequest<AccountSuccessResult>(
890
+ 'DELETE',
891
+ `/applications/${encodeURIComponent(applicationId)}`,
892
+ undefined,
893
+ { cache: false },
894
+ );
895
+ // Bust every cached representation of the deleted application.
896
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}`);
897
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}/credentials`);
898
+ this._invalidateAppLists();
899
+ return result;
900
+ } catch (error) {
901
+ throw this.handleError(error);
902
+ }
903
+ }
904
+
905
+ // =========================================================================
906
+ // Application OAuth credentials — /applications/:appId/credentials
907
+ // =========================================================================
908
+
909
+ /**
910
+ * List an application's OAuth credentials. The response NEVER includes
911
+ * secrets.
912
+ * @param applicationId - The application's Mongo `_id`.
913
+ */
914
+ async listAppCredentials(applicationId: string): Promise<ApplicationCredential[]> {
915
+ try {
916
+ const res = await this.makeRequest<{ credentials?: ApplicationCredential[] }>(
917
+ 'GET',
918
+ `/applications/${encodeURIComponent(applicationId)}/credentials`,
919
+ undefined,
920
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
921
+ );
922
+ return res.credentials ?? [];
923
+ } catch (error) {
924
+ throw this.handleError(error);
925
+ }
926
+ }
927
+
928
+ /**
929
+ * Create an application credential. The plaintext `secret` is returned
930
+ * exactly ONCE; the server stores only a hash and will never return it again.
931
+ * @param applicationId - The application's Mongo `_id`.
932
+ * @param data - Credential configuration.
933
+ */
934
+ async createAppCredential(
935
+ applicationId: string,
936
+ data: CreateApplicationCredentialInput,
937
+ ): Promise<ApplicationCredentialWithSecret> {
938
+ try {
939
+ const result = await this.makeRequest<ApplicationCredentialWithSecret>(
940
+ 'POST',
941
+ `/applications/${encodeURIComponent(applicationId)}/credentials`,
942
+ data,
943
+ { cache: false },
944
+ );
945
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}/credentials`);
946
+ return result;
947
+ } catch (error) {
948
+ throw this.handleError(error);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Rotate an application credential's secret. The new plaintext `secret` is
954
+ * returned exactly ONCE, along with audit fields: `rotatedFrom` (the previous
955
+ * credentialId) and `graceExpiresAt` (ISO string for the grace window during
956
+ * which the old credential is still honoured).
957
+ * @param applicationId - The application's Mongo `_id`.
958
+ * @param credentialId - The credential's Mongo `_id`.
959
+ */
960
+ async rotateAppCredential(
961
+ applicationId: string,
962
+ credentialId: string,
963
+ ): Promise<RotateApplicationCredentialResult> {
964
+ try {
965
+ const result = await this.makeRequest<RotateApplicationCredentialResult>(
966
+ 'POST',
967
+ `/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
968
+ undefined,
969
+ { cache: false },
970
+ );
971
+ // Rotation changes credential status/audit fields surfaced by the list.
972
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}/credentials`);
973
+ return result;
974
+ } catch (error) {
975
+ throw this.handleError(error);
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Revoke an application credential (`status='revoked'`). Revoked credentials
981
+ * can no longer authenticate.
982
+ * @param applicationId - The application's Mongo `_id`.
983
+ * @param credentialId - The credential's Mongo `_id`.
984
+ */
985
+ async revokeAppCredential(
986
+ applicationId: string,
987
+ credentialId: string,
988
+ ): Promise<AccountSuccessResult> {
989
+ try {
990
+ const result = await this.makeRequest<AccountSuccessResult>(
991
+ 'DELETE',
992
+ `/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}`,
993
+ undefined,
994
+ { cache: false },
995
+ );
996
+ // Revocation flips the credential's status in the cached list.
997
+ this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}/credentials`);
998
+ return result;
999
+ } catch (error) {
1000
+ throw this.handleError(error);
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Fetch usage statistics for an application.
1006
+ * @param applicationId - The application's Mongo `_id`.
1007
+ * @param period - Time window (defaults to the server default).
1008
+ */
1009
+ async getAppUsage(
1010
+ applicationId: string,
1011
+ period?: ApplicationUsagePeriod,
1012
+ ): Promise<ApplicationUsageStats> {
1013
+ try {
1014
+ return await this.makeRequest<ApplicationUsageStats>(
1015
+ 'GET',
1016
+ `/applications/${encodeURIComponent(applicationId)}/usage`,
1017
+ period ? { period } : undefined,
1018
+ { cache: true, cacheTTL: CACHE_TIMES.SHORT },
1019
+ );
1020
+ } catch (error) {
1021
+ throw this.handleError(error);
1022
+ }
1023
+ }
1024
+
1025
+ // =========================================================================
1026
+ // Cache-invalidation helpers
1027
+ // =========================================================================
1028
+
1029
+ /**
1030
+ * Bust every cached account list. `listAccounts({tree?})` keys the flat list
1031
+ * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
1032
+ * query string is part of the URL path). A change to the accessible forest
1033
+ * (create/archive/ownership transfer) invalidates both, so we clear the
1034
+ * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
1035
+ * `GET:/accounts?` matches only the query-string list variants, never the
1036
+ * `GET:/accounts/<id>…` detail/sub-resource keys.
1037
+ *
1038
+ * Internal helper (leading underscore); not part of the supported public
1039
+ * surface. Public rather than `private` because mixins compose into an
1040
+ * exported anonymous class, where TypeScript cannot represent a private
1041
+ * member in the emitted declaration file (TS4094).
1042
+ */
1043
+ _invalidateAccountLists(): void {
1044
+ this.clearCacheEntry('GET:/accounts');
1045
+ this.clearCacheByPrefix('GET:/accounts?');
1046
+ }
1047
+
1048
+ /**
1049
+ * Bust the cached member list and detail for an account after a membership
1050
+ * mutation. The member list (`listAccountMembers`) and the detail
1051
+ * (`getAccount`, which can embed the caller's membership) both go stale when
1052
+ * the member set or a member's role changes.
1053
+ *
1054
+ * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1055
+ * this is public rather than `private`.
1056
+ */
1057
+ _invalidateAccountMembership(accountId: string): void {
1058
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
1059
+ this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
1060
+ }
1061
+
1062
+ /**
1063
+ * Bust every cached application list. `listAccountApps(accountId)` keys each
1064
+ * owner-scoped list as `GET:/applications?ownerAccountId=<id>` (the query
1065
+ * string is part of the URL path). A change to any list (create/delete)
1066
+ * invalidates them all, so we clear the unscoped entry plus every `?`-query
1067
+ * variant via a prefix sweep. The prefix `GET:/applications?` matches only the
1068
+ * query-string list variants, never the `GET:/applications/<id>…`
1069
+ * detail/sub-resource keys.
1070
+ *
1071
+ * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1072
+ * this is public rather than `private`.
1073
+ */
1074
+ _invalidateAppLists(): void {
1075
+ this.clearCacheEntry('GET:/applications');
1076
+ this.clearCacheByPrefix('GET:/applications?');
1077
+ }
1078
+ };
1079
+ }