@oxyhq/core 2.4.0 → 3.0.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.js +1 -1
- package/dist/cjs/mixins/OxyServices.applications.js +212 -0
- package/dist/cjs/mixins/OxyServices.auth.js +4 -4
- package/dist/cjs/mixins/OxyServices.fedcm.js +52 -2
- package/dist/cjs/mixins/index.js +2 -2
- package/dist/cjs/utils/coldBoot.js +66 -17
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/mixins/OxyServices.applications.js +209 -0
- package/dist/esm/mixins/OxyServices.auth.js +4 -4
- package/dist/esm/mixins/OxyServices.fedcm.js +52 -2
- package/dist/esm/mixins/index.js +2 -2
- package/dist/esm/utils/coldBoot.js +66 -17
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.applications.d.ts +317 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -1
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/utils/coldBoot.d.ts +26 -0
- package/package.json +1 -1
- package/src/OxyServices.ts +1 -1
- package/src/index.ts +29 -0
- package/src/mixins/OxyServices.applications.ts +511 -0
- package/src/mixins/OxyServices.auth.ts +4 -4
- package/src/mixins/OxyServices.fedcm.ts +56 -2
- package/src/mixins/OxyServices.utility.ts +1 -1
- package/src/mixins/__tests__/fedcm.test.ts +52 -0
- package/src/mixins/index.ts +3 -3
- package/src/utils/__tests__/coldBoot.test.ts +150 -0
- package/src/utils/coldBoot.ts +96 -17
- package/src/mixins/OxyServices.developer.ts +0 -114
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applications Methods Mixin
|
|
3
|
+
*
|
|
4
|
+
* Provides methods for managing Oxy applications, their members, and their
|
|
5
|
+
* credentials via the `/applications` API. An application is a multi-user
|
|
6
|
+
* entity: membership (with a role) grants permissions; credentials
|
|
7
|
+
* (public/confidential/service) carry OAuth client identifiers and the
|
|
8
|
+
* service-token API key material.
|
|
9
|
+
*
|
|
10
|
+
* Reference applications by their Mongo `_id` (`applicationId`) and credentials
|
|
11
|
+
* by their `credentialId`. Never by name.
|
|
12
|
+
*/
|
|
13
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
14
|
+
import { CACHE_TIMES } from './mixinHelpers';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Application classification. Set only by Oxy platform staff — never editable
|
|
18
|
+
* through the normal member-facing update path.
|
|
19
|
+
*/
|
|
20
|
+
export type ApplicationType = 'first_party' | 'third_party' | 'internal' | 'system';
|
|
21
|
+
|
|
22
|
+
/** Lifecycle status of an application. */
|
|
23
|
+
export type ApplicationStatus = 'active' | 'suspended' | 'deleted' | 'pending_review';
|
|
24
|
+
|
|
25
|
+
/** Role a member holds within an application. */
|
|
26
|
+
export type ApplicationRole = 'owner' | 'admin' | 'developer' | 'viewer' | 'billing';
|
|
27
|
+
|
|
28
|
+
/** Membership lifecycle status. */
|
|
29
|
+
export type ApplicationMemberStatus = 'active' | 'invited' | 'removed';
|
|
30
|
+
|
|
31
|
+
/** Credential kind. `service` credentials mint service tokens. */
|
|
32
|
+
export type ApplicationCredentialType = 'public' | 'confidential' | 'service';
|
|
33
|
+
|
|
34
|
+
/** Deployment environment a credential is scoped to. */
|
|
35
|
+
export type ApplicationEnvironment = 'development' | 'staging' | 'production';
|
|
36
|
+
|
|
37
|
+
/** Credential lifecycle status. */
|
|
38
|
+
export type ApplicationCredentialStatus = 'active' | 'deprecated' | 'revoked';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Client-facing Application shape returned by the `/applications` API.
|
|
42
|
+
* Mirrors the server `Application` model with `_id` as a string and dates
|
|
43
|
+
* serialized to ISO strings.
|
|
44
|
+
*/
|
|
45
|
+
export interface Application {
|
|
46
|
+
_id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
websiteUrl?: string;
|
|
50
|
+
icon?: string;
|
|
51
|
+
type: ApplicationType;
|
|
52
|
+
status: ApplicationStatus;
|
|
53
|
+
isOfficial: boolean;
|
|
54
|
+
isInternal: boolean;
|
|
55
|
+
capabilities: string[];
|
|
56
|
+
redirectUris: string[];
|
|
57
|
+
scopes: string[];
|
|
58
|
+
webhookUrl?: string;
|
|
59
|
+
devWebhookUrl?: string;
|
|
60
|
+
createdByUserId: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
/**
|
|
64
|
+
* The calling user's own membership in this application, embedded by the API
|
|
65
|
+
* on list (`GET /applications`) and detail (`GET /applications/:appId`)
|
|
66
|
+
* responses. Use `callerMembership.permissions` to gate UI affordances.
|
|
67
|
+
*/
|
|
68
|
+
callerMembership?: ApplicationMember;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Client-facing ApplicationMember shape. `permissions` is derived from `role`
|
|
73
|
+
* on the server at write time.
|
|
74
|
+
*/
|
|
75
|
+
export interface ApplicationMember {
|
|
76
|
+
_id: string;
|
|
77
|
+
applicationId: string;
|
|
78
|
+
userId: string;
|
|
79
|
+
role: ApplicationRole;
|
|
80
|
+
permissions: string[];
|
|
81
|
+
invitedByUserId?: string;
|
|
82
|
+
joinedAt?: string;
|
|
83
|
+
status: ApplicationMemberStatus;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Client-facing ApplicationCredential shape. The raw secret is NEVER part of
|
|
90
|
+
* this shape — it is returned exactly once, separately, at creation/rotation.
|
|
91
|
+
*/
|
|
92
|
+
export interface ApplicationCredential {
|
|
93
|
+
_id: string;
|
|
94
|
+
applicationId: string;
|
|
95
|
+
name: string;
|
|
96
|
+
publicKey: string;
|
|
97
|
+
type: ApplicationCredentialType;
|
|
98
|
+
environment: ApplicationEnvironment;
|
|
99
|
+
scopes: string[];
|
|
100
|
+
status: ApplicationCredentialStatus;
|
|
101
|
+
lastUsedAt?: string;
|
|
102
|
+
expiresAt?: string;
|
|
103
|
+
createdByUserId: string;
|
|
104
|
+
createdAt: string;
|
|
105
|
+
updatedAt: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Input accepted by `createApplication`. Staff-only fields are not settable here. */
|
|
109
|
+
export interface CreateApplicationInput {
|
|
110
|
+
name: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
websiteUrl?: string;
|
|
113
|
+
icon?: string;
|
|
114
|
+
redirectUris?: string[];
|
|
115
|
+
scopes?: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Input accepted by `updateApplication`. Staff-only fields are not settable here. */
|
|
119
|
+
export interface UpdateApplicationInput {
|
|
120
|
+
name?: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
websiteUrl?: string;
|
|
123
|
+
icon?: string;
|
|
124
|
+
redirectUris?: string[];
|
|
125
|
+
scopes?: string[];
|
|
126
|
+
webhookUrl?: string;
|
|
127
|
+
devWebhookUrl?: string;
|
|
128
|
+
status?: ApplicationStatus;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Input accepted by `inviteApplicationMember`. The owner role cannot be invited. */
|
|
132
|
+
export interface InviteApplicationMemberInput {
|
|
133
|
+
userId: string;
|
|
134
|
+
role: Exclude<ApplicationRole, 'owner'>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Input accepted by `updateApplicationMember`. */
|
|
138
|
+
export interface UpdateApplicationMemberInput {
|
|
139
|
+
role: ApplicationRole;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Input accepted by `transferApplicationOwnership`. */
|
|
143
|
+
export interface TransferApplicationOwnershipInput {
|
|
144
|
+
userId: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Input accepted by `createApplicationCredential`. */
|
|
148
|
+
export interface CreateApplicationCredentialInput {
|
|
149
|
+
name: string;
|
|
150
|
+
type: ApplicationCredentialType;
|
|
151
|
+
environment: ApplicationEnvironment;
|
|
152
|
+
scopes?: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Result of creating or rotating a credential — `secret` is returned ONCE. */
|
|
156
|
+
export interface ApplicationCredentialWithSecret {
|
|
157
|
+
credential: ApplicationCredential;
|
|
158
|
+
secret: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Time window for application usage statistics. */
|
|
162
|
+
export type ApplicationUsagePeriod = '24h' | '7d' | '30d' | '90d';
|
|
163
|
+
|
|
164
|
+
/** Aggregate totals for an application over the requested period. */
|
|
165
|
+
export interface ApplicationUsageSummary {
|
|
166
|
+
totalRequests: number;
|
|
167
|
+
totalTokens: number;
|
|
168
|
+
totalCredits: number;
|
|
169
|
+
avgResponseTime: number;
|
|
170
|
+
successfulRequests: number;
|
|
171
|
+
errorRequests: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Per-day usage bucket. `_id` is the day key (e.g. `YYYY-MM-DD`). */
|
|
175
|
+
export interface ApplicationUsageByDay {
|
|
176
|
+
_id: string;
|
|
177
|
+
requests: number;
|
|
178
|
+
tokens: number;
|
|
179
|
+
credits: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Per-endpoint usage bucket. `_id` is the endpoint identifier. */
|
|
183
|
+
export interface ApplicationUsageByEndpoint {
|
|
184
|
+
_id: string;
|
|
185
|
+
requests: number;
|
|
186
|
+
tokens: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Usage statistics for an application over a period. */
|
|
190
|
+
export interface ApplicationUsageStats {
|
|
191
|
+
summary: ApplicationUsageSummary;
|
|
192
|
+
byDay: ApplicationUsageByDay[];
|
|
193
|
+
byEndpoint: ApplicationUsageByEndpoint[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Result of a delete/remove/revoke/transfer operation. */
|
|
197
|
+
export interface ApplicationSuccessResult {
|
|
198
|
+
success: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function OxyServicesApplicationsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
202
|
+
return class extends Base {
|
|
203
|
+
constructor(...args: any[]) {
|
|
204
|
+
super(...(args as [any]));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* List applications the current user is an active member of.
|
|
209
|
+
*/
|
|
210
|
+
async getApplications(): Promise<Application[]> {
|
|
211
|
+
try {
|
|
212
|
+
const res = await this.makeRequest<{ applications?: Application[] }>(
|
|
213
|
+
'GET',
|
|
214
|
+
'/applications',
|
|
215
|
+
undefined,
|
|
216
|
+
{ cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
|
|
217
|
+
);
|
|
218
|
+
return res.applications ?? [];
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw this.handleError(error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Create a new application. The caller becomes its `owner`.
|
|
226
|
+
* @param data - Application configuration. Staff-only fields are ignored.
|
|
227
|
+
*/
|
|
228
|
+
async createApplication(data: CreateApplicationInput): Promise<Application> {
|
|
229
|
+
try {
|
|
230
|
+
const res = await this.makeRequest<{ application: Application }>(
|
|
231
|
+
'POST',
|
|
232
|
+
'/applications',
|
|
233
|
+
data,
|
|
234
|
+
{ cache: false },
|
|
235
|
+
);
|
|
236
|
+
return res.application;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
throw this.handleError(error);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch a single application by id.
|
|
244
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
245
|
+
*/
|
|
246
|
+
async getApplication(applicationId: string): Promise<Application> {
|
|
247
|
+
try {
|
|
248
|
+
const res = await this.makeRequest<{ application: Application }>(
|
|
249
|
+
'GET',
|
|
250
|
+
`/applications/${applicationId}`,
|
|
251
|
+
undefined,
|
|
252
|
+
{ cache: true, cacheTTL: CACHE_TIMES.LONG },
|
|
253
|
+
);
|
|
254
|
+
return res.application;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw this.handleError(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Update an application's mutable fields.
|
|
262
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
263
|
+
* @param data - Subset of updatable fields. Staff-only fields are ignored.
|
|
264
|
+
*/
|
|
265
|
+
async updateApplication(
|
|
266
|
+
applicationId: string,
|
|
267
|
+
data: UpdateApplicationInput,
|
|
268
|
+
): Promise<Application> {
|
|
269
|
+
try {
|
|
270
|
+
const res = await this.makeRequest<{ application: Application }>(
|
|
271
|
+
'PATCH',
|
|
272
|
+
`/applications/${applicationId}`,
|
|
273
|
+
data,
|
|
274
|
+
{ cache: false },
|
|
275
|
+
);
|
|
276
|
+
return res.application;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw this.handleError(error);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Soft-delete an application (owner only).
|
|
284
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
285
|
+
*/
|
|
286
|
+
async deleteApplication(applicationId: string): Promise<ApplicationSuccessResult> {
|
|
287
|
+
try {
|
|
288
|
+
return await this.makeRequest<ApplicationSuccessResult>(
|
|
289
|
+
'DELETE',
|
|
290
|
+
`/applications/${applicationId}`,
|
|
291
|
+
undefined,
|
|
292
|
+
{ cache: false },
|
|
293
|
+
);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw this.handleError(error);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* List members of an application.
|
|
301
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
302
|
+
*/
|
|
303
|
+
async getApplicationMembers(applicationId: string): Promise<ApplicationMember[]> {
|
|
304
|
+
try {
|
|
305
|
+
const res = await this.makeRequest<{ members?: ApplicationMember[] }>(
|
|
306
|
+
'GET',
|
|
307
|
+
`/applications/${applicationId}/members`,
|
|
308
|
+
undefined,
|
|
309
|
+
{ cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
|
|
310
|
+
);
|
|
311
|
+
return res.members ?? [];
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw this.handleError(error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Add a member to an application.
|
|
319
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
320
|
+
* @param data - Target user id and role (never `owner`).
|
|
321
|
+
*/
|
|
322
|
+
async inviteApplicationMember(
|
|
323
|
+
applicationId: string,
|
|
324
|
+
data: InviteApplicationMemberInput,
|
|
325
|
+
): Promise<ApplicationMember> {
|
|
326
|
+
try {
|
|
327
|
+
const res = await this.makeRequest<{ member: ApplicationMember }>(
|
|
328
|
+
'POST',
|
|
329
|
+
`/applications/${applicationId}/members`,
|
|
330
|
+
data,
|
|
331
|
+
{ cache: false },
|
|
332
|
+
);
|
|
333
|
+
return res.member;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw this.handleError(error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Change a member's role.
|
|
341
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
342
|
+
* @param memberId - The member's Mongo `_id`.
|
|
343
|
+
* @param data - New role.
|
|
344
|
+
*/
|
|
345
|
+
async updateApplicationMember(
|
|
346
|
+
applicationId: string,
|
|
347
|
+
memberId: string,
|
|
348
|
+
data: UpdateApplicationMemberInput,
|
|
349
|
+
): Promise<ApplicationMember> {
|
|
350
|
+
try {
|
|
351
|
+
const res = await this.makeRequest<{ member: ApplicationMember }>(
|
|
352
|
+
'PATCH',
|
|
353
|
+
`/applications/${applicationId}/members/${memberId}`,
|
|
354
|
+
data,
|
|
355
|
+
{ cache: false },
|
|
356
|
+
);
|
|
357
|
+
return res.member;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw this.handleError(error);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Remove a member from an application.
|
|
365
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
366
|
+
* @param memberId - The member's Mongo `_id`.
|
|
367
|
+
*/
|
|
368
|
+
async removeApplicationMember(
|
|
369
|
+
applicationId: string,
|
|
370
|
+
memberId: string,
|
|
371
|
+
): Promise<ApplicationSuccessResult> {
|
|
372
|
+
try {
|
|
373
|
+
return await this.makeRequest<ApplicationSuccessResult>(
|
|
374
|
+
'DELETE',
|
|
375
|
+
`/applications/${applicationId}/members/${memberId}`,
|
|
376
|
+
undefined,
|
|
377
|
+
{ cache: false },
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw this.handleError(error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Transfer ownership of an application to another member (owner only).
|
|
386
|
+
* Demotes the current owner to `admin` and promotes the target to `owner`.
|
|
387
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
388
|
+
* @param data - Target user id.
|
|
389
|
+
*/
|
|
390
|
+
async transferApplicationOwnership(
|
|
391
|
+
applicationId: string,
|
|
392
|
+
data: TransferApplicationOwnershipInput,
|
|
393
|
+
): Promise<ApplicationSuccessResult> {
|
|
394
|
+
try {
|
|
395
|
+
return await this.makeRequest<ApplicationSuccessResult>(
|
|
396
|
+
'POST',
|
|
397
|
+
`/applications/${applicationId}/transfer-ownership`,
|
|
398
|
+
data,
|
|
399
|
+
{ cache: false },
|
|
400
|
+
);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw this.handleError(error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* List an application's credentials. The response NEVER includes secrets.
|
|
408
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
409
|
+
*/
|
|
410
|
+
async getApplicationCredentials(applicationId: string): Promise<ApplicationCredential[]> {
|
|
411
|
+
try {
|
|
412
|
+
const res = await this.makeRequest<{ credentials?: ApplicationCredential[] }>(
|
|
413
|
+
'GET',
|
|
414
|
+
`/applications/${applicationId}/credentials`,
|
|
415
|
+
undefined,
|
|
416
|
+
{ cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
|
|
417
|
+
);
|
|
418
|
+
return res.credentials ?? [];
|
|
419
|
+
} catch (error) {
|
|
420
|
+
throw this.handleError(error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create a credential. The plaintext `secret` is returned exactly ONCE;
|
|
426
|
+
* the server stores only a hash and will never return it again.
|
|
427
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
428
|
+
* @param data - Credential configuration.
|
|
429
|
+
*/
|
|
430
|
+
async createApplicationCredential(
|
|
431
|
+
applicationId: string,
|
|
432
|
+
data: CreateApplicationCredentialInput,
|
|
433
|
+
): Promise<ApplicationCredentialWithSecret> {
|
|
434
|
+
try {
|
|
435
|
+
return await this.makeRequest<ApplicationCredentialWithSecret>(
|
|
436
|
+
'POST',
|
|
437
|
+
`/applications/${applicationId}/credentials`,
|
|
438
|
+
data,
|
|
439
|
+
{ cache: false },
|
|
440
|
+
);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw this.handleError(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Rotate a credential's secret. The new plaintext `secret` is returned
|
|
448
|
+
* exactly ONCE.
|
|
449
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
450
|
+
* @param credentialId - The credential's Mongo `_id`.
|
|
451
|
+
*/
|
|
452
|
+
async rotateApplicationCredential(
|
|
453
|
+
applicationId: string,
|
|
454
|
+
credentialId: string,
|
|
455
|
+
): Promise<ApplicationCredentialWithSecret> {
|
|
456
|
+
try {
|
|
457
|
+
return await this.makeRequest<ApplicationCredentialWithSecret>(
|
|
458
|
+
'POST',
|
|
459
|
+
`/applications/${applicationId}/credentials/${credentialId}/rotate`,
|
|
460
|
+
undefined,
|
|
461
|
+
{ cache: false },
|
|
462
|
+
);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw this.handleError(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Revoke a credential (`status='revoked'`). Revoked credentials can no
|
|
470
|
+
* longer authenticate.
|
|
471
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
472
|
+
* @param credentialId - The credential's Mongo `_id`.
|
|
473
|
+
*/
|
|
474
|
+
async revokeApplicationCredential(
|
|
475
|
+
applicationId: string,
|
|
476
|
+
credentialId: string,
|
|
477
|
+
): Promise<ApplicationSuccessResult> {
|
|
478
|
+
try {
|
|
479
|
+
return await this.makeRequest<ApplicationSuccessResult>(
|
|
480
|
+
'DELETE',
|
|
481
|
+
`/applications/${applicationId}/credentials/${credentialId}`,
|
|
482
|
+
undefined,
|
|
483
|
+
{ cache: false },
|
|
484
|
+
);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
throw this.handleError(error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Fetch usage statistics for an application.
|
|
492
|
+
* @param applicationId - The application's Mongo `_id`.
|
|
493
|
+
* @param period - Time window (defaults to the server default).
|
|
494
|
+
*/
|
|
495
|
+
async getApplicationUsage(
|
|
496
|
+
applicationId: string,
|
|
497
|
+
period?: ApplicationUsagePeriod,
|
|
498
|
+
): Promise<ApplicationUsageStats> {
|
|
499
|
+
try {
|
|
500
|
+
return await this.makeRequest<ApplicationUsageStats>(
|
|
501
|
+
'GET',
|
|
502
|
+
`/applications/${applicationId}/usage`,
|
|
503
|
+
period ? { period } : undefined,
|
|
504
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
505
|
+
);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
throw this.handleError(error);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
@@ -157,8 +157,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
157
157
|
* legitimate multi-tenant hosts that need to switch credentials cannot leak
|
|
158
158
|
* one tenant's token to another tenant on the same instance.
|
|
159
159
|
*
|
|
160
|
-
* @param apiKey -
|
|
161
|
-
* @param apiSecret -
|
|
160
|
+
* @param apiKey - Application credential public key (oxy_dk_*)
|
|
161
|
+
* @param apiSecret - Application credential secret
|
|
162
162
|
*/
|
|
163
163
|
configureServiceAuth(apiKey: string, apiSecret: string): void {
|
|
164
164
|
this._serviceApiKey = apiKey;
|
|
@@ -181,8 +181,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
181
181
|
* This prevents an attacker who learned a peer's apiKey from extracting
|
|
182
182
|
* their service token by polling with a wrong secret.
|
|
183
183
|
*
|
|
184
|
-
* @param apiKey -
|
|
185
|
-
* @param apiSecret -
|
|
184
|
+
* @param apiKey - Application credential public key (optional if configureServiceAuth was called)
|
|
185
|
+
* @param apiSecret - Application credential secret (optional if configureServiceAuth was called)
|
|
186
186
|
*/
|
|
187
187
|
async getServiceToken(apiKey?: string, apiSecret?: string): Promise<string> {
|
|
188
188
|
const key = apiKey || this._serviceApiKey;
|
|
@@ -214,6 +214,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
214
214
|
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
215
215
|
public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
|
|
216
216
|
|
|
217
|
+
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
218
|
+
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
219
|
+
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
220
|
+
// within this window (keeping the existing error path intact). If — as seen
|
|
221
|
+
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
222
|
+
// awaited promise never settles, the hard settle resolves the request to
|
|
223
|
+
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
224
|
+
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
225
|
+
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
226
|
+
public static readonly FEDCM_ABORT_SETTLE_GRACE_MS = 500;
|
|
227
|
+
|
|
217
228
|
/**
|
|
218
229
|
* Check if FedCM is supported in the current browser
|
|
219
230
|
*/
|
|
@@ -606,6 +617,37 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
606
617
|
controller.abort();
|
|
607
618
|
}, timeoutMs);
|
|
608
619
|
|
|
620
|
+
// Hard settle guarantee for the timeout path.
|
|
621
|
+
//
|
|
622
|
+
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
623
|
+
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
624
|
+
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
625
|
+
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
626
|
+
// to settle the awaited promise in every Chrome version / internal state
|
|
627
|
+
// (the credential request can sit "pending" while the browser-side flow is
|
|
628
|
+
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
629
|
+
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
630
|
+
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
631
|
+
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
632
|
+
//
|
|
633
|
+
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
634
|
+
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
635
|
+
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
636
|
+
// never settles, the race resolves and the step falls through cleanly to the
|
|
637
|
+
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
638
|
+
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
639
|
+
// the existing error path) before we force a clean `null`.
|
|
640
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
641
|
+
const settlePromise = new Promise<FedCMIdentityCredential | null>((resolve) => {
|
|
642
|
+
const ctor = this.constructor as typeof OxyServicesBase & {
|
|
643
|
+
FEDCM_ABORT_SETTLE_GRACE_MS: number;
|
|
644
|
+
};
|
|
645
|
+
settleTimer = setTimeout(() => {
|
|
646
|
+
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
647
|
+
resolve(null);
|
|
648
|
+
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
649
|
+
});
|
|
650
|
+
|
|
609
651
|
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
610
652
|
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
611
653
|
// synchronous TypeError, in which case we retry with the legacy value.
|
|
@@ -645,7 +687,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
645
687
|
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
646
688
|
let credential: FedCMIdentityCredential | null;
|
|
647
689
|
try {
|
|
648
|
-
|
|
690
|
+
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
691
|
+
// a `credentials.get` that ignores the abort signal can never hang
|
|
692
|
+
// the cold boot (see `settlePromise`).
|
|
693
|
+
credential = await Promise.race([
|
|
694
|
+
credentials.get(buildCredentialOptions(modernMode)),
|
|
695
|
+
settlePromise,
|
|
696
|
+
]);
|
|
649
697
|
} catch (modeError) {
|
|
650
698
|
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
651
699
|
// throws a synchronous TypeError for the modern 'active'/'passive'
|
|
@@ -653,7 +701,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
653
701
|
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
654
702
|
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
655
703
|
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
656
|
-
credential = await
|
|
704
|
+
credential = await Promise.race([
|
|
705
|
+
credentials.get(buildCredentialOptions(legacyMode)),
|
|
706
|
+
settlePromise,
|
|
707
|
+
]);
|
|
657
708
|
} else {
|
|
658
709
|
throw modeError;
|
|
659
710
|
}
|
|
@@ -680,6 +731,9 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
680
731
|
throw error;
|
|
681
732
|
} finally {
|
|
682
733
|
clearTimeout(timeout);
|
|
734
|
+
if (settleTimer !== undefined) {
|
|
735
|
+
clearTimeout(settleTimer);
|
|
736
|
+
}
|
|
683
737
|
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
684
738
|
// interactive request aborts a slow silent one, the silent settles (and
|
|
685
739
|
// runs this `finally`) AFTER the interactive has already taken over the
|
|
@@ -54,7 +54,7 @@ export interface ServiceActingAsVerification {
|
|
|
54
54
|
/**
|
|
55
55
|
* Service app metadata attached to requests authenticated with service tokens.
|
|
56
56
|
* `scopes` reflects the scopes granted to the app at signup time (from the
|
|
57
|
-
* `
|
|
57
|
+
* `Application.scopes` field); route-level checks can require additional
|
|
58
58
|
* scope-narrowing via `requireScope()`.
|
|
59
59
|
*/
|
|
60
60
|
export interface ServiceApp {
|
|
@@ -192,6 +192,58 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
192
192
|
|
|
193
193
|
await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Production-hang regression. `navigator.credentials.get()` is a
|
|
198
|
+
* browser-internal FedCM primitive that, in some Chrome states, IGNORES its
|
|
199
|
+
* abort signal — the awaited promise never settles. The cooperative
|
|
200
|
+
* `setTimeout`→`controller.abort()` alone cannot unblock that await, so the
|
|
201
|
+
* `fedcm-silent` cold-boot step (and the whole cold boot) hangs forever and
|
|
202
|
+
* the terminal `/sso` bounce never fires.
|
|
203
|
+
*
|
|
204
|
+
* The hard settle guarantee (`Promise.race` against a timer that resolves
|
|
205
|
+
* `null` at `FEDCM_SILENT_TIMEOUT + FEDCM_ABORT_SETTLE_GRACE_MS`) must resolve
|
|
206
|
+
* the request to `null` regardless. This test models the hung primitive and
|
|
207
|
+
* asserts `silentSignInWithFedCM()` settles to `null` within that bound.
|
|
208
|
+
*/
|
|
209
|
+
it('hard-settles to null when navigator.credentials.get never settles (ignores abort)', async () => {
|
|
210
|
+
jest.useFakeTimers();
|
|
211
|
+
try {
|
|
212
|
+
installBrowserGlobals({
|
|
213
|
+
// Never resolves or rejects, and never observes the abort signal —
|
|
214
|
+
// models the production hung FedCM credential request.
|
|
215
|
+
credentialsGet: () => new Promise<unknown>(() => {}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
219
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
220
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
221
|
+
if (url === '/fedcm/nonce') {
|
|
222
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`unexpected request to ${url}`);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
let settled = false;
|
|
228
|
+
const promise = oxy.silentSignInWithFedCM().then((r) => {
|
|
229
|
+
settled = true;
|
|
230
|
+
return r;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Before the hard-settle deadline it must still be pending — proving the
|
|
234
|
+
// primitive really is hung (so the test would fail without the fix).
|
|
235
|
+
await jest.advanceTimersByTimeAsync(4000);
|
|
236
|
+
expect(settled).toBe(false);
|
|
237
|
+
|
|
238
|
+
// FEDCM_SILENT_TIMEOUT (4000) + FEDCM_ABORT_SETTLE_GRACE_MS (500) = 4500.
|
|
239
|
+
await jest.advanceTimersByTimeAsync(600);
|
|
240
|
+
await expect(promise).resolves.toBeNull();
|
|
241
|
+
expect(settled).toBe(true);
|
|
242
|
+
} finally {
|
|
243
|
+
jest.runOnlyPendingTimers();
|
|
244
|
+
jest.useRealTimers();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
195
247
|
});
|
|
196
248
|
|
|
197
249
|
/**
|