@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.
Files changed (35) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.js +1 -1
  3. package/dist/cjs/mixins/OxyServices.applications.js +212 -0
  4. package/dist/cjs/mixins/OxyServices.auth.js +4 -4
  5. package/dist/cjs/mixins/OxyServices.fedcm.js +52 -2
  6. package/dist/cjs/mixins/index.js +2 -2
  7. package/dist/cjs/utils/coldBoot.js +66 -17
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/OxyServices.js +1 -1
  10. package/dist/esm/mixins/OxyServices.applications.js +209 -0
  11. package/dist/esm/mixins/OxyServices.auth.js +4 -4
  12. package/dist/esm/mixins/OxyServices.fedcm.js +52 -2
  13. package/dist/esm/mixins/index.js +2 -2
  14. package/dist/esm/utils/coldBoot.js +66 -17
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/OxyServices.d.ts +1 -1
  17. package/dist/types/index.d.ts +1 -0
  18. package/dist/types/mixins/OxyServices.applications.d.ts +317 -0
  19. package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
  20. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  21. package/dist/types/mixins/OxyServices.utility.d.ts +1 -1
  22. package/dist/types/mixins/index.d.ts +2 -2
  23. package/dist/types/utils/coldBoot.d.ts +26 -0
  24. package/package.json +1 -1
  25. package/src/OxyServices.ts +1 -1
  26. package/src/index.ts +29 -0
  27. package/src/mixins/OxyServices.applications.ts +511 -0
  28. package/src/mixins/OxyServices.auth.ts +4 -4
  29. package/src/mixins/OxyServices.fedcm.ts +56 -2
  30. package/src/mixins/OxyServices.utility.ts +1 -1
  31. package/src/mixins/__tests__/fedcm.test.ts +52 -0
  32. package/src/mixins/index.ts +3 -3
  33. package/src/utils/__tests__/coldBoot.test.ts +150 -0
  34. package/src/utils/coldBoot.ts +96 -17
  35. package/src/mixins/OxyServices.developer.ts +0 -114
@@ -17,7 +17,7 @@ import { OxyServicesLanguageMixin } from './OxyServices.language';
17
17
  import { OxyServicesPaymentMixin } from './OxyServices.payment';
18
18
  import { OxyServicesKarmaMixin } from './OxyServices.karma';
19
19
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
20
- import { OxyServicesDeveloperMixin } from './OxyServices.developer';
20
+ import { OxyServicesApplicationsMixin } from './OxyServices.applications';
21
21
  import { OxyServicesLocationMixin } from './OxyServices.location';
22
22
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
23
23
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -50,7 +50,7 @@ type AllMixinInstances =
50
50
  & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
51
51
  & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>>
52
52
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
53
- & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>>
53
+ & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>>
54
54
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
55
55
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
56
56
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -114,7 +114,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
114
114
  OxyServicesPaymentMixin,
115
115
  OxyServicesKarmaMixin,
116
116
  OxyServicesAssetsMixin,
117
- OxyServicesDeveloperMixin,
117
+ OxyServicesApplicationsMixin,
118
118
  OxyServicesLocationMixin,
119
119
  OxyServicesAnalyticsMixin,
120
120
  OxyServicesDevicesMixin,
@@ -223,4 +223,154 @@ describe('runColdBoot', () => {
223
223
  session: { userId: 'u-ok' },
224
224
  });
225
225
  });
226
+
227
+ describe('overall deadline (defense-in-depth)', () => {
228
+ beforeEach(() => {
229
+ jest.useFakeTimers();
230
+ });
231
+ afterEach(() => {
232
+ jest.runOnlyPendingTimers();
233
+ jest.useRealTimers();
234
+ });
235
+
236
+ /**
237
+ * Reproduces the production hang: a step whose `run()` promise NEVER
238
+ * settles (the FedCM-silent `navigator.credentials.get` that ignored its
239
+ * abort signal). WITHOUT a deadline the whole `runColdBoot` promise hangs
240
+ * forever and the terminal step never runs.
241
+ */
242
+ it('hangs forever when a step never settles and no deadline is set', async () => {
243
+ const terminalRan = jest.fn();
244
+ let settled = false;
245
+
246
+ const outcomePromise = runColdBoot<TestSession>({
247
+ steps: [
248
+ {
249
+ id: 'never-settles',
250
+ // Never resolves or rejects — models the hung FedCM credential get.
251
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
252
+ },
253
+ {
254
+ id: 'terminal',
255
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
256
+ terminalRan();
257
+ return { kind: 'skip' };
258
+ },
259
+ },
260
+ ],
261
+ }).then((o) => {
262
+ settled = true;
263
+ return o;
264
+ });
265
+
266
+ // Advance well past any reasonable budget; nothing can unblock it.
267
+ await jest.advanceTimersByTimeAsync(120000);
268
+
269
+ expect(settled).toBe(false);
270
+ expect(terminalRan).not.toHaveBeenCalled();
271
+
272
+ // Avoid a dangling unhandled promise in the test runner.
273
+ void outcomePromise;
274
+ });
275
+
276
+ /**
277
+ * With `overallDeadlineMs` set, the non-settling step is abandoned at the
278
+ * deadline, the runner CONTINUES to the terminal step (so the cross-domain
279
+ * `/sso` bounce equivalent still fires), and the whole boot settles to
280
+ * `unauthenticated` within the bounded budget.
281
+ */
282
+ it('abandons a non-settling step at the deadline and still runs the terminal step', async () => {
283
+ const terminalRan = jest.fn();
284
+ const onStepDeadline = jest.fn();
285
+
286
+ const outcomePromise = runColdBoot<TestSession>({
287
+ overallDeadlineMs: 5000,
288
+ onStepDeadline,
289
+ steps: [
290
+ {
291
+ id: 'never-settles',
292
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
293
+ },
294
+ {
295
+ id: 'terminal',
296
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
297
+ terminalRan();
298
+ return { kind: 'skip' };
299
+ },
300
+ },
301
+ ],
302
+ });
303
+
304
+ await jest.advanceTimersByTimeAsync(5000);
305
+ const outcome = await outcomePromise;
306
+
307
+ expect(onStepDeadline).toHaveBeenCalledWith('never-settles');
308
+ expect(terminalRan).toHaveBeenCalledTimes(1);
309
+ expect(outcome).toEqual({ kind: 'unauthenticated' });
310
+ });
311
+
312
+ /**
313
+ * The terminal step's synchronous side effect (the real `sso-bounce`
314
+ * navigates BEFORE its first await) must still execute when the deadline
315
+ * trips on an earlier step — the cross-domain fallback is preserved.
316
+ */
317
+ it('lets the terminal step fire its synchronous side effect after the deadline trips', async () => {
318
+ const bounced = jest.fn();
319
+
320
+ const outcomePromise = runColdBoot<TestSession>({
321
+ overallDeadlineMs: 3000,
322
+ steps: [
323
+ {
324
+ id: 'never-settles',
325
+ run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
326
+ },
327
+ {
328
+ id: 'sso-bounce',
329
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
330
+ // Synchronous navigation side effect, exactly like the real bounce.
331
+ bounced();
332
+ return { kind: 'skip' };
333
+ },
334
+ },
335
+ ],
336
+ });
337
+
338
+ await jest.advanceTimersByTimeAsync(3000);
339
+ await outcomePromise;
340
+
341
+ expect(bounced).toHaveBeenCalledTimes(1);
342
+ });
343
+
344
+ /**
345
+ * A healthy step that settles BEFORE the deadline still wins and
346
+ * short-circuits — the deadline never alters the happy path.
347
+ */
348
+ it('a step that settles before the deadline wins and short-circuits', async () => {
349
+ const laterRan = jest.fn();
350
+
351
+ const outcomePromise = runColdBoot<TestSession>({
352
+ overallDeadlineMs: 10000,
353
+ steps: [
354
+ {
355
+ id: 'fast-winner',
356
+ run: async (): Promise<ColdBootStepResult<TestSession>> => {
357
+ await Promise.resolve();
358
+ return { kind: 'session', session: { userId: 'u-fast' } };
359
+ },
360
+ },
361
+ sessionStep('later', 'u-later', laterRan),
362
+ ],
363
+ });
364
+
365
+ await jest.advanceTimersByTimeAsync(0);
366
+ const outcome = await outcomePromise;
367
+
368
+ expect(outcome).toEqual({
369
+ kind: 'session',
370
+ via: 'fast-winner',
371
+ session: { userId: 'u-fast' },
372
+ });
373
+ expect(laterRan).not.toHaveBeenCalled();
374
+ });
375
+ });
226
376
  });
@@ -73,6 +73,16 @@ export type ColdBootOutcome<S> =
73
73
  | { readonly kind: 'session'; readonly via: string; readonly session: S }
74
74
  | { readonly kind: 'unauthenticated' };
75
75
 
76
+ /**
77
+ * The unique sentinel a step's `run()` resolves to (via the internal race)
78
+ * when the overall cold-boot deadline expires before that step settled. It is
79
+ * NOT a {@link ColdBootStepResult} — the runner detects it by identity and
80
+ * treats it as "this step did not settle in time; move on".
81
+ *
82
+ * @internal
83
+ */
84
+ const DEADLINE_EXPIRED: unique symbol = Symbol('coldBoot.deadlineExpired');
85
+
76
86
  /**
77
87
  * Options for {@link runColdBoot}.
78
88
  */
@@ -85,6 +95,32 @@ export interface RunColdBootOptions<S> {
85
95
  * the runner does not guard against an observer that itself throws.
86
96
  */
87
97
  readonly onStepError?: (id: string, error: unknown) => void;
98
+ /**
99
+ * Optional HARD overall deadline (ms) for the entire ordered step loop —
100
+ * defense-in-depth so a single non-settling step can NEVER hang the whole
101
+ * cold boot forever.
102
+ *
103
+ * Each step's `run()` is raced against the SHARED remaining time. If a step
104
+ * fails to settle before the deadline, the runner abandons the await for that
105
+ * step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
106
+ * each now racing against an already-expired deadline. This is deliberate:
107
+ * the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
108
+ * whose `run()` performs its side effect synchronously before its first
109
+ * `await`) still gets to fire. A step that has nothing to contribute after
110
+ * the deadline simply doesn't settle and is skipped in turn.
111
+ *
112
+ * Per-step timeouts inside `run()` remain the first line of defense and
113
+ * should keep every step well under this budget on a healthy load; this only
114
+ * trips when one of them regresses (the production FedCM-silent hang). When
115
+ * omitted there is NO overall deadline (unchanged legacy behaviour).
116
+ */
117
+ readonly overallDeadlineMs?: number;
118
+ /**
119
+ * Optional observer invoked once per step that was abandoned because the
120
+ * overall deadline expired before it settled. Receives the step `id`. Must
121
+ * not throw.
122
+ */
123
+ readonly onStepDeadline?: (id: string) => void;
88
124
  }
89
125
 
90
126
  /**
@@ -105,32 +141,75 @@ export interface RunColdBootOptions<S> {
105
141
  export async function runColdBoot<S>(
106
142
  options: RunColdBootOptions<S>
107
143
  ): Promise<ColdBootOutcome<S>> {
108
- const { steps, onStepError } = options;
144
+ const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
109
145
 
110
- for (const step of steps) {
111
- if (step.enabled) {
112
- let isEnabled: boolean;
146
+ // Arm the optional overall deadline. The budget is SHARED across the whole
147
+ // loop (not reset per step): a single timer resolves a reusable
148
+ // `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
149
+ // fires, later steps race against an already-resolved promise and so never
150
+ // block, yet the loop keeps iterating so the terminal step still fires.
151
+ const deadlineMs =
152
+ typeof overallDeadlineMs === 'number' &&
153
+ Number.isFinite(overallDeadlineMs) &&
154
+ overallDeadlineMs > 0
155
+ ? overallDeadlineMs
156
+ : null;
157
+
158
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
159
+ let deadlinePromise: Promise<typeof DEADLINE_EXPIRED> | undefined;
160
+ if (deadlineMs !== null) {
161
+ deadlinePromise = new Promise<typeof DEADLINE_EXPIRED>((resolve) => {
162
+ deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
163
+ });
164
+ }
165
+
166
+ try {
167
+ for (const step of steps) {
168
+ if (step.enabled) {
169
+ let isEnabled: boolean;
170
+ try {
171
+ isEnabled = step.enabled();
172
+ } catch (error) {
173
+ onStepError?.(step.id, error);
174
+ continue;
175
+ }
176
+ if (!isEnabled) continue;
177
+ }
178
+
179
+ let result: ColdBootStepResult<S> | typeof DEADLINE_EXPIRED;
113
180
  try {
114
- isEnabled = step.enabled();
181
+ // Without a deadline: legacy behaviour — await the step directly.
182
+ // With a deadline: race the step against the shared deadline. The
183
+ // step's `run()` still STARTS synchronously up to its first `await`
184
+ // (so a terminal step's synchronous navigation side effect always
185
+ // executes), but a non-settling step can no longer block the loop —
186
+ // the race resolves with the sentinel and we move on.
187
+ result = deadlinePromise
188
+ ? await Promise.race([step.run(), deadlinePromise])
189
+ : await step.run();
115
190
  } catch (error) {
116
191
  onStepError?.(step.id, error);
117
192
  continue;
118
193
  }
119
- if (!isEnabled) continue;
120
- }
121
194
 
122
- let result: ColdBootStepResult<S>;
123
- try {
124
- result = await step.run();
125
- } catch (error) {
126
- onStepError?.(step.id, error);
127
- continue;
195
+ if (result === DEADLINE_EXPIRED) {
196
+ // The deadline tripped before this step settled. Abandon the await and
197
+ // continue: subsequent steps race against the already-resolved deadline
198
+ // (so they cannot block), which lets a terminal side-effect step still
199
+ // run while guaranteeing the loop terminates promptly.
200
+ onStepDeadline?.(step.id);
201
+ continue;
202
+ }
203
+
204
+ if (result.kind === 'session') {
205
+ return { kind: 'session', via: step.id, session: result.session };
206
+ }
128
207
  }
129
208
 
130
- if (result.kind === 'session') {
131
- return { kind: 'session', via: step.id, session: result.session };
209
+ return { kind: 'unauthenticated' };
210
+ } finally {
211
+ if (deadlineTimer !== undefined) {
212
+ clearTimeout(deadlineTimer);
132
213
  }
133
214
  }
134
-
135
- return { kind: 'unauthenticated' };
136
215
  }
@@ -1,114 +0,0 @@
1
- /**
2
- * Developer API Methods Mixin
3
- *
4
- * Provides methods for managing developer applications and API keys
5
- */
6
- import type { OxyServicesBase } from '../OxyServices.base';
7
- import { CACHE_TIMES } from './mixinHelpers';
8
-
9
- export function OxyServicesDeveloperMixin<T extends typeof OxyServicesBase>(Base: T) {
10
- return class extends Base {
11
- constructor(...args: any[]) {
12
- super(...(args as [any]));
13
- }
14
-
15
- /**
16
- * Get developer apps for the current user
17
- * @returns Array of developer apps
18
- */
19
- async getDeveloperApps(): Promise<any[]> {
20
- try {
21
- const res = await this.makeRequest<{ apps?: any[] }>('GET', '/developer/apps', undefined, {
22
- cache: true,
23
- cacheTTL: CACHE_TIMES.MEDIUM,
24
- });
25
- return res.apps || [];
26
- } catch (error) {
27
- throw this.handleError(error);
28
- }
29
- }
30
-
31
- /**
32
- * Create a new developer app
33
- * @param data - Developer app configuration
34
- * @returns Created developer app
35
- */
36
- async createDeveloperApp(data: {
37
- name: string;
38
- description?: string;
39
- webhookUrl: string;
40
- devWebhookUrl?: string;
41
- scopes?: string[];
42
- }): Promise<any> {
43
- try {
44
- const res = await this.makeRequest<{ app: any }>('POST', '/developer/apps', data, { cache: false });
45
- return res.app;
46
- } catch (error) {
47
- throw this.handleError(error);
48
- }
49
- }
50
-
51
- /**
52
- * Get a specific developer app
53
- */
54
- async getDeveloperApp(appId: string): Promise<any> {
55
- try {
56
- const res = await this.makeRequest<{ app: any }>('GET', `/developer/apps/${appId}`, undefined, {
57
- cache: true,
58
- cacheTTL: CACHE_TIMES.LONG,
59
- });
60
- return res.app;
61
- } catch (error) {
62
- throw this.handleError(error);
63
- }
64
- }
65
-
66
- /**
67
- * Update a developer app
68
- * @param appId - The developer app ID
69
- * @param data - Updated app configuration
70
- * @returns Updated developer app
71
- */
72
- async updateDeveloperApp(appId: string, data: {
73
- name?: string;
74
- description?: string;
75
- webhookUrl?: string;
76
- devWebhookUrl?: string;
77
- scopes?: string[];
78
- }): Promise<any> {
79
- try {
80
- const res = await this.makeRequest<{ app: any }>('PATCH', `/developer/apps/${appId}`, data, { cache: false });
81
- return res.app;
82
- } catch (error) {
83
- throw this.handleError(error);
84
- }
85
- }
86
-
87
- /**
88
- * Regenerate API secret for a developer app
89
- * @param appId - The developer app ID
90
- * @returns App with new secret
91
- */
92
- async regenerateDeveloperAppSecret(appId: string): Promise<any> {
93
- try {
94
- return await this.makeRequest('POST', `/developer/apps/${appId}/regenerate-secret`, undefined, { cache: false });
95
- } catch (error) {
96
- throw this.handleError(error);
97
- }
98
- }
99
-
100
- /**
101
- * Delete a developer app
102
- * @param appId - The developer app ID
103
- * @returns Deletion result
104
- */
105
- async deleteDeveloperApp(appId: string): Promise<any> {
106
- try {
107
- return await this.makeRequest('DELETE', `/developer/apps/${appId}`, undefined, { cache: false });
108
- } catch (error) {
109
- throw this.handleError(error);
110
- }
111
- }
112
- };
113
- }
114
-