@oxyhq/core 3.0.0 → 3.2.0

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.
@@ -58,12 +58,22 @@ export interface Application {
58
58
  webhookUrl?: string;
59
59
  devWebhookUrl?: string;
60
60
  createdByUserId: string;
61
+ /**
62
+ * The workspace this application belongs to (workspace `_id`), or `null` for
63
+ * applications not owned by a workspace. Used by the console to scope apps to
64
+ * a workspace and to branch on workspace-derived access.
65
+ */
66
+ workspaceId: string | null;
61
67
  createdAt: string;
62
68
  updatedAt: string;
63
69
  /**
64
70
  * The calling user's own membership in this application, embedded by the API
65
71
  * on list (`GET /applications`) and detail (`GET /applications/:appId`)
66
72
  * responses. Use `callerMembership.permissions` to gate UI affordances.
73
+ *
74
+ * When the caller's access is derived from a workspace membership rather than
75
+ * a direct application membership, the API returns a synthetic membership
76
+ * with `source: 'workspace'` and `_id: null`.
67
77
  */
68
78
  callerMembership?: ApplicationMember;
69
79
  }
@@ -73,7 +83,11 @@ export interface Application {
73
83
  * on the server at write time.
74
84
  */
75
85
  export interface ApplicationMember {
76
- _id: string;
86
+ /**
87
+ * The membership's Mongo `_id`. `null` for a synthetic, workspace-derived
88
+ * membership (see {@link Application.callerMembership} and `source`).
89
+ */
90
+ _id: string | null;
77
91
  applicationId: string;
78
92
  userId: string;
79
93
  role: ApplicationRole;
@@ -81,6 +95,13 @@ export interface ApplicationMember {
81
95
  invitedByUserId?: string;
82
96
  joinedAt?: string;
83
97
  status: ApplicationMemberStatus;
98
+ /**
99
+ * Origin of this membership. When `'workspace'`, the membership is synthetic
100
+ * and derived from the caller's workspace membership rather than a direct
101
+ * application membership (in which case `_id` is `null`). Absent or any other
102
+ * value indicates a direct application membership.
103
+ */
104
+ source?: 'workspace';
84
105
  createdAt: string;
85
106
  updatedAt: string;
86
107
  }
@@ -100,11 +121,49 @@ export interface ApplicationCredential {
100
121
  status: ApplicationCredentialStatus;
101
122
  lastUsedAt?: string;
102
123
  expiresAt?: string;
124
+ /**
125
+ * Audit link to the credential this one was rotated FROM. Populated by the
126
+ * API on credentials created via rotation; absent on original credentials.
127
+ */
128
+ rotatedFromCredentialId?: string;
103
129
  createdByUserId: string;
104
130
  createdAt: string;
105
131
  updatedAt: string;
106
132
  }
107
133
 
134
+ /**
135
+ * Sanitized, PUBLIC application identity returned by the API when resolving a
136
+ * cross-app/OAuth client to a registered {@link Application}.
137
+ *
138
+ * Unlike {@link Application}, this shape carries NO sensitive or membership
139
+ * fields — it is safe to display unauthenticated in consent/authorize screens
140
+ * and device-flow approval UIs. The API resolves a `client_id` (OAuth
141
+ * credential public key) to the owning application and projects only the
142
+ * fields below. `id` is the application's `_id` as a string.
143
+ */
144
+ export interface PublicApplication {
145
+ /** The application's Mongo `_id` as a string. */
146
+ id: string;
147
+ /** Human-readable application name shown to the user. */
148
+ name: string;
149
+ /** Optional short description of what the application does. */
150
+ description?: string;
151
+ /** Optional icon URL for the application. */
152
+ icon?: string;
153
+ /** Optional public website/homepage URL for the application. */
154
+ websiteUrl?: string;
155
+ /** Application classification (set by Oxy platform staff). */
156
+ type: ApplicationType;
157
+ /** Whether the application is an officially endorsed Oxy application. */
158
+ isOfficial: boolean;
159
+ /** Whether the application is an internal Oxy ecosystem application. */
160
+ isInternal: boolean;
161
+ /** OAuth scopes the application is configured to request. */
162
+ scopes: string[];
163
+ /** Optional display name of the developer/owner organisation. */
164
+ developerName?: string;
165
+ }
166
+
108
167
  /** Input accepted by `createApplication`. Staff-only fields are not settable here. */
109
168
  export interface CreateApplicationInput {
110
169
  name: string;
@@ -113,6 +172,11 @@ export interface CreateApplicationInput {
113
172
  icon?: string;
114
173
  redirectUris?: string[];
115
174
  scopes?: string[];
175
+ /**
176
+ * Optional workspace `_id` to create the app in. Omitted → API defaults to
177
+ * the caller's personal workspace.
178
+ */
179
+ workspaceId?: string;
116
180
  }
117
181
 
118
182
  /** Input accepted by `updateApplication`. Staff-only fields are not settable here. */
@@ -152,12 +216,25 @@ export interface CreateApplicationCredentialInput {
152
216
  scopes?: string[];
153
217
  }
154
218
 
155
- /** Result of creating or rotating a credential — `secret` is returned ONCE. */
219
+ /** Result of creating a credential — `secret` is returned ONCE. */
156
220
  export interface ApplicationCredentialWithSecret {
157
221
  credential: ApplicationCredential;
158
222
  secret: string;
159
223
  }
160
224
 
225
+ /**
226
+ * Result of rotating a credential. Extends the create result with audit fields:
227
+ * the new plaintext `secret` is returned ONCE, plus `rotatedFrom` (the previous
228
+ * credential's `credentialId`) and `graceExpiresAt` (ISO string marking when the
229
+ * old credential stops being honoured during the rotation grace window).
230
+ */
231
+ export interface RotateApplicationCredentialResult extends ApplicationCredentialWithSecret {
232
+ /** The previous credential's `credentialId` that this rotation supersedes. */
233
+ rotatedFrom: string;
234
+ /** ISO timestamp at which the rotated-from credential's grace window ends. */
235
+ graceExpiresAt: string;
236
+ }
237
+
161
238
  /** Time window for application usage statistics. */
162
239
  export type ApplicationUsagePeriod = '24h' | '7d' | '30d' | '90d';
163
240
 
@@ -204,14 +281,47 @@ export function OxyServicesApplicationsMixin<T extends typeof OxyServicesBase>(B
204
281
  super(...(args as [any]));
205
282
  }
206
283
 
284
+ /**
285
+ * Resolve an OAuth client identifier to the owning application's PUBLIC
286
+ * identity. No authentication required — the API returns only sanitized,
287
+ * display-safe metadata ({@link PublicApplication}). Use this to render the
288
+ * requesting application's name/icon in consent, authorize, and device-flow
289
+ * approval UIs before any session exists.
290
+ *
291
+ * @param clientId - The OAuth `client_id` (an active credential's public
292
+ * key). URL-encoded before being placed in the path.
293
+ */
294
+ async getPublicApplication(clientId: string): Promise<PublicApplication> {
295
+ try {
296
+ const res = await this.makeRequest<{ application: PublicApplication }>(
297
+ 'GET',
298
+ `/auth/oauth/client/${encodeURIComponent(clientId)}`,
299
+ undefined,
300
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
301
+ );
302
+ return res.application;
303
+ } catch (error) {
304
+ throw this.handleError(error);
305
+ }
306
+ }
307
+
207
308
  /**
208
309
  * List applications the current user is an active member of.
310
+ *
311
+ * @param workspaceId - Optional workspace `_id` to scope the listing to
312
+ * applications belonging to that workspace. When provided it is appended
313
+ * as a `workspaceId` query parameter (URL-encoded). The query string is
314
+ * part of the request path, so the response cache keys on it
315
+ * automatically — scoped and unscoped lists never collide.
209
316
  */
210
- async getApplications(): Promise<Application[]> {
317
+ async getApplications(workspaceId?: string): Promise<Application[]> {
211
318
  try {
319
+ const path = workspaceId
320
+ ? `/applications?workspaceId=${encodeURIComponent(workspaceId)}`
321
+ : '/applications';
212
322
  const res = await this.makeRequest<{ applications?: Application[] }>(
213
323
  'GET',
214
- '/applications',
324
+ path,
215
325
  undefined,
216
326
  { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
217
327
  );
@@ -445,16 +555,18 @@ export function OxyServicesApplicationsMixin<T extends typeof OxyServicesBase>(B
445
555
 
446
556
  /**
447
557
  * Rotate a credential's secret. The new plaintext `secret` is returned
448
- * exactly ONCE.
558
+ * exactly ONCE, along with audit fields: `rotatedFrom` (the previous
559
+ * credentialId) and `graceExpiresAt` (ISO string for the grace window during
560
+ * which the old credential is still honoured).
449
561
  * @param applicationId - The application's Mongo `_id`.
450
562
  * @param credentialId - The credential's Mongo `_id`.
451
563
  */
452
564
  async rotateApplicationCredential(
453
565
  applicationId: string,
454
566
  credentialId: string,
455
- ): Promise<ApplicationCredentialWithSecret> {
567
+ ): Promise<RotateApplicationCredentialResult> {
456
568
  try {
457
- return await this.makeRequest<ApplicationCredentialWithSecret>(
569
+ return await this.makeRequest<RotateApplicationCredentialResult>(
458
570
  'POST',
459
571
  `/applications/${applicationId}/credentials/${credentialId}/rotate`,
460
572
  undefined,
@@ -18,6 +18,7 @@ interface JwtPayload {
18
18
  sessionId?: string;
19
19
  type?: string;
20
20
  appId?: string;
21
+ credentialId?: string;
21
22
  appName?: string;
22
23
  scopes?: string[];
23
24
  aud?: string | string[];
@@ -61,6 +62,13 @@ export interface ServiceApp {
61
62
  appId: string;
62
63
  appName: string;
63
64
  scopes: string[];
65
+ /**
66
+ * The credentialId of the specific service credential that minted this token.
67
+ * Carried by newer service-token JWTs alongside `appId`; absent on tokens
68
+ * issued before credential-level audit linking. Use for per-credential audit
69
+ * trails and rotation alignment (GitHub #215).
70
+ */
71
+ credentialId?: string;
64
72
  }
65
73
 
66
74
  /**
@@ -618,6 +626,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
618
626
  appId,
619
627
  appName: decoded.appName || 'unknown',
620
628
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
629
+ ...(typeof decoded.credentialId === 'string' && decoded.credentialId.length > 0
630
+ ? { credentialId: decoded.credentialId }
631
+ : {}),
621
632
  };
622
633
 
623
634
  if (debug) {
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Workspaces Methods Mixin
3
+ *
4
+ * Provides methods for managing Oxy workspaces and their members via the
5
+ * `/workspaces` API. A workspace is a multi-user container that owns
6
+ * applications and other resources: membership (with a role) grants
7
+ * permissions. A `personal` workspace is created implicitly for every user;
8
+ * `team` workspaces are created explicitly and can invite additional members.
9
+ *
10
+ * Reference workspaces by their Mongo `_id` and members by their member `_id`.
11
+ * Never by name or slug.
12
+ */
13
+ import type { OxyServicesBase } from '../OxyServices.base';
14
+ import { CACHE_TIMES } from './mixinHelpers';
15
+
16
+ /** Role a member holds within a workspace. */
17
+ export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer';
18
+
19
+ /** Workspace classification. A `personal` workspace is implicit per user. */
20
+ export type WorkspaceType = 'personal' | 'team';
21
+
22
+ /** Lifecycle status of a workspace. */
23
+ export type WorkspaceStatus = 'active' | 'deleted';
24
+
25
+ /** Membership lifecycle status. */
26
+ export type WorkspaceMemberStatus = 'active' | 'invited' | 'removed';
27
+
28
+ /**
29
+ * Client-facing WorkspaceMember shape. `permissions` is derived from `role`
30
+ * on the server at write time.
31
+ */
32
+ export interface WorkspaceMember {
33
+ _id: string;
34
+ workspaceId: string;
35
+ userId: string;
36
+ role: WorkspaceRole;
37
+ permissions: string[];
38
+ invitedByUserId?: string | null;
39
+ joinedAt?: string | null;
40
+ status: WorkspaceMemberStatus;
41
+ createdAt: string;
42
+ updatedAt: string;
43
+ }
44
+
45
+ /**
46
+ * Client-facing Workspace shape returned by the `/workspaces` API. Mirrors the
47
+ * server `Workspace` model with `_id` as a string and dates serialized to ISO
48
+ * strings.
49
+ */
50
+ export interface Workspace {
51
+ _id: string;
52
+ name: string;
53
+ slug: string;
54
+ type: WorkspaceType;
55
+ description?: string | null;
56
+ icon?: string | null;
57
+ ownerId: string;
58
+ status: WorkspaceStatus;
59
+ createdAt: string;
60
+ updatedAt: string;
61
+ /**
62
+ * The calling user's own membership in this workspace, embedded by the API
63
+ * on list (`GET /workspaces`) and detail (`GET /workspaces/:id`) responses.
64
+ * Use `callerMembership.permissions` to gate UI affordances.
65
+ */
66
+ callerMembership?: WorkspaceMember | null;
67
+ }
68
+
69
+ /** Input accepted by `createWorkspace`. */
70
+ export interface CreateWorkspaceInput {
71
+ name: string;
72
+ description?: string;
73
+ icon?: string;
74
+ }
75
+
76
+ /** Input accepted by `updateWorkspace`. */
77
+ export interface UpdateWorkspaceInput {
78
+ name?: string;
79
+ description?: string | null;
80
+ icon?: string | null;
81
+ }
82
+
83
+ /** Input accepted by `inviteWorkspaceMember`. The owner role cannot be invited. */
84
+ export interface InviteWorkspaceMemberInput {
85
+ userId: string;
86
+ role: Exclude<WorkspaceRole, 'owner'>;
87
+ }
88
+
89
+ /** Input accepted by `updateWorkspaceMember`. The owner role cannot be assigned. */
90
+ export interface UpdateWorkspaceMemberInput {
91
+ role: Exclude<WorkspaceRole, 'owner'>;
92
+ }
93
+
94
+ /** Input accepted by `transferWorkspaceOwnership`. */
95
+ export interface TransferWorkspaceOwnershipInput {
96
+ userId: string;
97
+ }
98
+
99
+ /** Result of a delete/remove/transfer operation. */
100
+ export interface WorkspaceSuccessResult {
101
+ success: boolean;
102
+ }
103
+
104
+ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Base: T) {
105
+ return class extends Base {
106
+ constructor(...args: any[]) {
107
+ super(...(args as [any]));
108
+ }
109
+
110
+ /**
111
+ * List workspaces the current user is an active member of.
112
+ */
113
+ async getWorkspaces(): Promise<Workspace[]> {
114
+ try {
115
+ const res = await this.makeRequest<{ workspaces?: Workspace[] }>(
116
+ 'GET',
117
+ '/workspaces',
118
+ undefined,
119
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
120
+ );
121
+ return res.workspaces ?? [];
122
+ } catch (error) {
123
+ throw this.handleError(error);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Create a new team workspace. The caller becomes its `owner`.
129
+ * @param data - Workspace configuration.
130
+ */
131
+ async createWorkspace(data: CreateWorkspaceInput): Promise<Workspace> {
132
+ try {
133
+ const res = await this.makeRequest<{ workspace: Workspace }>(
134
+ 'POST',
135
+ '/workspaces',
136
+ data,
137
+ { cache: false },
138
+ );
139
+ return res.workspace;
140
+ } catch (error) {
141
+ throw this.handleError(error);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Fetch a single workspace by id.
147
+ * @param workspaceId - The workspace's Mongo `_id`.
148
+ */
149
+ async getWorkspace(workspaceId: string): Promise<Workspace> {
150
+ try {
151
+ const res = await this.makeRequest<{ workspace: Workspace }>(
152
+ 'GET',
153
+ `/workspaces/${encodeURIComponent(workspaceId)}`,
154
+ undefined,
155
+ { cache: true, cacheTTL: CACHE_TIMES.LONG },
156
+ );
157
+ return res.workspace;
158
+ } catch (error) {
159
+ throw this.handleError(error);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Update a workspace's mutable fields.
165
+ * @param workspaceId - The workspace's Mongo `_id`.
166
+ * @param data - Subset of updatable fields.
167
+ */
168
+ async updateWorkspace(
169
+ workspaceId: string,
170
+ data: UpdateWorkspaceInput,
171
+ ): Promise<Workspace> {
172
+ try {
173
+ const res = await this.makeRequest<{ workspace: Workspace }>(
174
+ 'PATCH',
175
+ `/workspaces/${encodeURIComponent(workspaceId)}`,
176
+ data,
177
+ { cache: false },
178
+ );
179
+ return res.workspace;
180
+ } catch (error) {
181
+ throw this.handleError(error);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Soft-delete a workspace (owner only).
187
+ * @param workspaceId - The workspace's Mongo `_id`.
188
+ */
189
+ async deleteWorkspace(workspaceId: string): Promise<WorkspaceSuccessResult> {
190
+ try {
191
+ return await this.makeRequest<WorkspaceSuccessResult>(
192
+ 'DELETE',
193
+ `/workspaces/${encodeURIComponent(workspaceId)}`,
194
+ undefined,
195
+ { cache: false },
196
+ );
197
+ } catch (error) {
198
+ throw this.handleError(error);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * List members of a workspace.
204
+ * @param workspaceId - The workspace's Mongo `_id`.
205
+ */
206
+ async getWorkspaceMembers(workspaceId: string): Promise<WorkspaceMember[]> {
207
+ try {
208
+ const res = await this.makeRequest<{ members?: WorkspaceMember[] }>(
209
+ 'GET',
210
+ `/workspaces/${encodeURIComponent(workspaceId)}/members`,
211
+ undefined,
212
+ { cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
213
+ );
214
+ return res.members ?? [];
215
+ } catch (error) {
216
+ throw this.handleError(error);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Add a member to a workspace.
222
+ * @param workspaceId - The workspace's Mongo `_id`.
223
+ * @param data - Target user id and role (never `owner`).
224
+ */
225
+ async inviteWorkspaceMember(
226
+ workspaceId: string,
227
+ data: InviteWorkspaceMemberInput,
228
+ ): Promise<WorkspaceMember> {
229
+ try {
230
+ const res = await this.makeRequest<{ member: WorkspaceMember }>(
231
+ 'POST',
232
+ `/workspaces/${encodeURIComponent(workspaceId)}/members`,
233
+ data,
234
+ { cache: false },
235
+ );
236
+ return res.member;
237
+ } catch (error) {
238
+ throw this.handleError(error);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Change a member's role.
244
+ * @param workspaceId - The workspace's Mongo `_id`.
245
+ * @param memberId - The member's Mongo `_id`.
246
+ * @param data - New role (never `owner`).
247
+ */
248
+ async updateWorkspaceMember(
249
+ workspaceId: string,
250
+ memberId: string,
251
+ data: UpdateWorkspaceMemberInput,
252
+ ): Promise<WorkspaceMember> {
253
+ try {
254
+ const res = await this.makeRequest<{ member: WorkspaceMember }>(
255
+ 'PATCH',
256
+ `/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
257
+ data,
258
+ { cache: false },
259
+ );
260
+ return res.member;
261
+ } catch (error) {
262
+ throw this.handleError(error);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Remove a member from a workspace.
268
+ * @param workspaceId - The workspace's Mongo `_id`.
269
+ * @param memberId - The member's Mongo `_id`.
270
+ */
271
+ async removeWorkspaceMember(
272
+ workspaceId: string,
273
+ memberId: string,
274
+ ): Promise<WorkspaceSuccessResult> {
275
+ try {
276
+ return await this.makeRequest<WorkspaceSuccessResult>(
277
+ 'DELETE',
278
+ `/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
279
+ undefined,
280
+ { cache: false },
281
+ );
282
+ } catch (error) {
283
+ throw this.handleError(error);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Transfer ownership of a workspace to another member (owner only).
289
+ * Demotes the current owner and promotes the target to `owner`.
290
+ * @param workspaceId - The workspace's Mongo `_id`.
291
+ * @param data - Target user id.
292
+ */
293
+ async transferWorkspaceOwnership(
294
+ workspaceId: string,
295
+ data: TransferWorkspaceOwnershipInput,
296
+ ): Promise<WorkspaceSuccessResult> {
297
+ try {
298
+ return await this.makeRequest<WorkspaceSuccessResult>(
299
+ 'POST',
300
+ `/workspaces/${encodeURIComponent(workspaceId)}/transfer-ownership`,
301
+ data,
302
+ { cache: false },
303
+ );
304
+ } catch (error) {
305
+ throw this.handleError(error);
306
+ }
307
+ }
308
+ };
309
+ }
@@ -18,6 +18,7 @@ import { OxyServicesPaymentMixin } from './OxyServices.payment';
18
18
  import { OxyServicesKarmaMixin } from './OxyServices.karma';
19
19
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
20
20
  import { OxyServicesApplicationsMixin } from './OxyServices.applications';
21
+ import { OxyServicesWorkspacesMixin } from './OxyServices.workspaces';
21
22
  import { OxyServicesLocationMixin } from './OxyServices.location';
22
23
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
23
24
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -51,6 +52,7 @@ type AllMixinInstances =
51
52
  & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>>
52
53
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
53
54
  & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>>
55
+ & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>>
54
56
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
55
57
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
56
58
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -115,6 +117,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
115
117
  OxyServicesKarmaMixin,
116
118
  OxyServicesAssetsMixin,
117
119
  OxyServicesApplicationsMixin,
120
+ OxyServicesWorkspacesMixin,
118
121
  OxyServicesLocationMixin,
119
122
  OxyServicesAnalyticsMixin,
120
123
  OxyServicesDevicesMixin,
@@ -17,6 +17,15 @@ export interface OxyConfig {
17
17
  sessionBaseUrl?: string;
18
18
  authWebUrl?: string;
19
19
  authRedirectUri?: string;
20
+ /**
21
+ * The app's Oxy OAuth client id (ApplicationCredential publicKey).
22
+ *
23
+ * Identifies this app in OAuth authorize / consent flows (issue #214). Purely
24
+ * declarative: the SDK stores it on `OxyServices.config.clientId` for later
25
+ * OAuth-authorize use. It is unrelated to the cross-domain `/sso?client_id=…`
26
+ * bounce (which uses the RP origin, not this registered client id).
27
+ */
28
+ clientId?: string;
20
29
  // Performance & caching options
21
30
  enableCache?: boolean;
22
31
  cacheTTL?: number; // Cache TTL in milliseconds (default: 5 minutes)