@oxyhq/core 12.10.0 → 12.10.2

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 (86) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +22 -12
  3. package/dist/cjs/i18n/locales/ar-SA.json +4 -0
  4. package/dist/cjs/i18n/locales/ca-ES.json +4 -0
  5. package/dist/cjs/i18n/locales/de-DE.json +4 -0
  6. package/dist/cjs/i18n/locales/en-US.json +69 -3
  7. package/dist/cjs/i18n/locales/es-ES.json +68 -2
  8. package/dist/cjs/i18n/locales/fr-FR.json +4 -0
  9. package/dist/cjs/i18n/locales/it-IT.json +4 -0
  10. package/dist/cjs/i18n/locales/ja-JP.json +4 -0
  11. package/dist/cjs/i18n/locales/ko-KR.json +4 -0
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +4 -0
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +4 -0
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +4 -0
  15. package/dist/cjs/i18n/locales/locales/en-US.json +69 -3
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +68 -2
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +4 -0
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +4 -0
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +4 -0
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +4 -0
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +4 -0
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +4 -0
  23. package/dist/cjs/i18n/locales/pt-PT.json +4 -0
  24. package/dist/cjs/i18n/locales/zh-CN.json +4 -0
  25. package/dist/cjs/mixins/OxyServices.auth.js +47 -19
  26. package/dist/cjs/mixins/OxyServices.connectedApps.js +6 -1
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -2
  28. package/dist/cjs/mixins/OxyServices.user.js +2 -0
  29. package/dist/cjs/session/accountDialogController.js +7 -5
  30. package/dist/esm/.tsbuildinfo +1 -1
  31. package/dist/esm/HttpService.js +22 -12
  32. package/dist/esm/i18n/locales/ar-SA.json +4 -0
  33. package/dist/esm/i18n/locales/ca-ES.json +4 -0
  34. package/dist/esm/i18n/locales/de-DE.json +4 -0
  35. package/dist/esm/i18n/locales/en-US.json +69 -3
  36. package/dist/esm/i18n/locales/es-ES.json +68 -2
  37. package/dist/esm/i18n/locales/fr-FR.json +4 -0
  38. package/dist/esm/i18n/locales/it-IT.json +4 -0
  39. package/dist/esm/i18n/locales/ja-JP.json +4 -0
  40. package/dist/esm/i18n/locales/ko-KR.json +4 -0
  41. package/dist/esm/i18n/locales/locales/ar-SA.json +4 -0
  42. package/dist/esm/i18n/locales/locales/ca-ES.json +4 -0
  43. package/dist/esm/i18n/locales/locales/de-DE.json +4 -0
  44. package/dist/esm/i18n/locales/locales/en-US.json +69 -3
  45. package/dist/esm/i18n/locales/locales/es-ES.json +68 -2
  46. package/dist/esm/i18n/locales/locales/fr-FR.json +4 -0
  47. package/dist/esm/i18n/locales/locales/it-IT.json +4 -0
  48. package/dist/esm/i18n/locales/locales/ja-JP.json +4 -0
  49. package/dist/esm/i18n/locales/locales/ko-KR.json +4 -0
  50. package/dist/esm/i18n/locales/locales/pt-PT.json +4 -0
  51. package/dist/esm/i18n/locales/locales/zh-CN.json +4 -0
  52. package/dist/esm/i18n/locales/pt-PT.json +4 -0
  53. package/dist/esm/i18n/locales/zh-CN.json +4 -0
  54. package/dist/esm/mixins/OxyServices.auth.js +47 -19
  55. package/dist/esm/mixins/OxyServices.connectedApps.js +6 -1
  56. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -2
  57. package/dist/esm/mixins/OxyServices.user.js +2 -0
  58. package/dist/esm/session/accountDialogController.js +7 -5
  59. package/dist/types/.tsbuildinfo +1 -1
  60. package/dist/types/HttpService.d.ts +14 -0
  61. package/dist/types/session/accountDialogController.d.ts +7 -2
  62. package/package.json +1 -1
  63. package/src/HttpService.ts +37 -14
  64. package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
  65. package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
  66. package/src/i18n/locales/ar-SA.json +4 -0
  67. package/src/i18n/locales/ca-ES.json +4 -0
  68. package/src/i18n/locales/de-DE.json +4 -0
  69. package/src/i18n/locales/en-US.json +69 -3
  70. package/src/i18n/locales/es-ES.json +68 -2
  71. package/src/i18n/locales/fr-FR.json +4 -0
  72. package/src/i18n/locales/it-IT.json +4 -0
  73. package/src/i18n/locales/ja-JP.json +4 -0
  74. package/src/i18n/locales/ko-KR.json +4 -0
  75. package/src/i18n/locales/pt-PT.json +4 -0
  76. package/src/i18n/locales/zh-CN.json +4 -0
  77. package/src/mixins/OxyServices.auth.ts +39 -19
  78. package/src/mixins/OxyServices.connectedApps.ts +6 -1
  79. package/src/mixins/OxyServices.deviceBoot.ts +7 -2
  80. package/src/mixins/OxyServices.user.ts +2 -0
  81. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  82. package/src/mixins/__tests__/commonsSignIn.test.ts +4 -4
  83. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +152 -0
  84. package/src/mixins/__tests__/webauthnAuth.test.ts +6 -6
  85. package/src/session/__tests__/accountDialogController.test.ts +3 -3
  86. package/src/session/accountDialogController.ts +7 -6
@@ -40,6 +40,20 @@ export interface RequestOptions {
40
40
  * mint call fully independent of the current (near-expired) bearer.
41
41
  */
42
42
  skipAuth?: boolean;
43
+ /**
44
+ * Execute this request WITHOUT taking a `RequestQueue` slot — run it directly.
45
+ *
46
+ * A queue slot represents network occupancy for ordinary DATA requests. The
47
+ * CONTROL-PLANE calls the auth lane depends on (the device-secret mint, `POST
48
+ * /session/device/token`, reached from `getAuthHeader` → `refreshAccessToken`)
49
+ * must never compete for a slot: when `maxConcurrentRequests` requests are all
50
+ * parked awaiting that very mint, a queued mint could never acquire a slot to
51
+ * run — a systemic deadlock. `bypassQueue` lets the mint run even when the
52
+ * queue is saturated. (The auth preflight is also resolved OUTSIDE the slot in
53
+ * `request()`, so ordinary requests never hold a slot while awaiting the mint;
54
+ * this flag is the explicit guarantee for the mint itself.)
55
+ */
56
+ bypassQueue?: boolean;
43
57
  }
44
58
  interface RequestConfig extends RequestOptions {
45
59
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -35,6 +35,13 @@
35
35
  * password, social login, and 2FA were removed ecosystem-wide. Account
36
36
  * creation (`signup` view) is the same two identity backends: a passkey
37
37
  * ceremony on web, or a Commons-created identity.
38
+ *
39
+ * The controller owns NO surface PRESENTATION: whether the account dialog is
40
+ * mounted, visible, or dismissed is the job of the shared surface stack
41
+ * (`@oxyhq/services` presents the `AccountDialog` surface into `@oxyhq/bloom`'s
42
+ * stack). This is a headless state machine only — it exposes `setView` /
43
+ * `add` / `startSignup` (the view axis) and `cancelSignIn` (device-flow
44
+ * teardown), never an `open` / `close` / `visible`.
38
45
  */
39
46
  import type { OxyServices } from '../OxyServices';
40
47
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
@@ -294,8 +301,6 @@ export declare class AccountDialogController {
294
301
  private reconcileAuth;
295
302
  /** Set the dialog view directly. */
296
303
  setView(view: AccountDialogView): void;
297
- /** Return to the account list and cancel any in-flight sign-in flow. */
298
- close(): void;
299
304
  /** Switch to the "add account" view (the sign-in entry chooser). */
300
305
  add(): void;
301
306
  /** Switch to the "create account" view (passkey / Commons signup entry). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.10.0",
3
+ "version": "12.10.2",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -85,6 +85,20 @@ export interface RequestOptions {
85
85
  * mint call fully independent of the current (near-expired) bearer.
86
86
  */
87
87
  skipAuth?: boolean;
88
+ /**
89
+ * Execute this request WITHOUT taking a `RequestQueue` slot — run it directly.
90
+ *
91
+ * A queue slot represents network occupancy for ordinary DATA requests. The
92
+ * CONTROL-PLANE calls the auth lane depends on (the device-secret mint, `POST
93
+ * /session/device/token`, reached from `getAuthHeader` → `refreshAccessToken`)
94
+ * must never compete for a slot: when `maxConcurrentRequests` requests are all
95
+ * parked awaiting that very mint, a queued mint could never acquire a slot to
96
+ * run — a systemic deadlock. `bypassQueue` lets the mint run even when the
97
+ * queue is saturated. (The auth preflight is also resolved OUTSIDE the slot in
98
+ * `request()`, so ordinary requests never hold a slot while awaiting the mint;
99
+ * this flag is the explicit guarantee for the mint itself.)
100
+ */
101
+ bypassQueue?: boolean;
88
102
  }
89
103
 
90
104
  interface RequestConfig extends RequestOptions {
@@ -414,24 +428,29 @@ export class HttpService {
414
428
  this.requestMetrics.cacheMisses++;
415
429
  }
416
430
 
431
+ // Resolve the auth preflight OUTSIDE the queue slot. A slot represents
432
+ // NETWORK OCCUPANCY only. `getAuthHeader` may await the single-flight token
433
+ // refresh/mint — itself a request — so if a slot-holding request awaited it
434
+ // here and every slot were held by requests all waiting on that same mint,
435
+ // the mint could never acquire a slot to run (systemic deadlock). Resolving
436
+ // it before enqueue means auth-blocked requests hold NO slot while the shared
437
+ // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
438
+ // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
439
+ // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
440
+ // these here with the refreshed token / cleared CSRF.
441
+ const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
442
+ const authHeader = config.skipAuth ? null : await this.getAuthHeader();
443
+ // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
444
+ // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
445
+ // should not need to implement a duplicate `/csrf-token` route.
446
+ const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
447
+
417
448
  // Request function
418
449
  const requestFn = async (): Promise<T> => {
419
450
  const startTime = Date.now();
420
451
  try {
421
452
  // Build URL with params
422
453
  const fullUrl = this.buildURL(url, params);
423
-
424
- // Get auth token (with auto-refresh). `skipAuth` requests (the
425
- // body-authenticated refresh endpoint) send NO bearer and skip the
426
- // near-expiry preflight — see RequestOptions.skipAuth for the deadlock
427
- // this avoids.
428
- const authHeader = config.skipAuth ? null : await this.getAuthHeader();
429
-
430
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated
431
- // SDK clients are not vulnerable to ambient-cookie CSRF, and linked app
432
- // APIs should not need to implement a duplicate `/csrf-token` route.
433
- const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
434
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
435
454
 
436
455
  // Determine if data is FormData using robust detection
437
456
  const isFormData = this.isFormData(data);
@@ -665,8 +684,12 @@ export class HttpService {
665
684
  ? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
666
685
  : requestWithRetry;
667
686
 
668
- // Execute request (with queue if needed)
669
- const result = await this.requestQueue.enqueue(finalRequest);
687
+ // Execute the request. Control-plane calls the auth lane depends on
688
+ // (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
689
+ // could never acquire a slot when every slot is parked awaiting it.
690
+ const result = config.bypassQueue
691
+ ? await finalRequest()
692
+ : await this.requestQueue.enqueue(finalRequest);
670
693
 
671
694
  // Cache the result if caching is enabled
672
695
  if (cache && cacheKey && result) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Auth self-await regression.
3
+ *
4
+ * The refresh handler (arm 2 shared-key sign-in) calls PRE-SESSION public
5
+ * endpoints (`/auth/challenge`, `/auth/verify`, the commons-signin surface, …)
6
+ * from INSIDE the single-flight `tokenRefreshPromise`. If any of those carried
7
+ * an auth preflight (no `skipAuth`), its `getAuthHeader` would call
8
+ * `refreshAccessToken`, find the handler's own promise already in flight, and
9
+ * await it — the handler awaiting itself → permanent hang (no deadline on the
10
+ * auto-connect lane makes it visible). `skipAuth` on every pre-session public
11
+ * endpoint is what prevents this; these tests lock the invariant.
12
+ */
13
+ import { HttpService } from '../HttpService';
14
+
15
+ function createJwt(payload: Record<string, unknown>): string {
16
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
17
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
18
+ }
19
+
20
+ function jsonResponse(data: unknown): Response {
21
+ return new Response(JSON.stringify({ data }), {
22
+ status: 200,
23
+ headers: { 'content-type': 'application/json' },
24
+ });
25
+ }
26
+
27
+ const nowSec = (): number => Math.floor(Date.now() / 1000);
28
+
29
+ /** Resolve to `true` if `promise` settles within `ms`, else `false`. Always
30
+ * clears its timer so no pending timeout leaks into jest's teardown. */
31
+ async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
33
+ const timeout = new Promise<false>((resolve) => {
34
+ timer = setTimeout(() => resolve(false), ms);
35
+ });
36
+ try {
37
+ return await Promise.race([promise.then(() => true), timeout]);
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+
43
+ describe('HttpService auth self-await', () => {
44
+ const originalFetch = globalThis.fetch;
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ it('a skipAuth request issued from INSIDE the refresh handler resolves (no self-await)', async () => {
52
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
53
+ const url = String(input);
54
+ if (url.includes('/auth/challenge')) return jsonResponse({ challenge: 'c' });
55
+ return jsonResponse({ ok: true });
56
+ }) as typeof fetch;
57
+
58
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
59
+ // Near-expiry → the data request's preflight starts the refresh.
60
+ http.setTokens(createJwt({ userId: 'u', exp: nowSec() + 30 }));
61
+
62
+ http.setAuthRefreshHandler(async () => {
63
+ // Mirror arm 2: from inside the handler, hit a pre-session public endpoint.
64
+ // With skipAuth it does NOT re-enter refreshAccessToken; without it, its
65
+ // preflight would await THIS handler's own pending promise → self-await.
66
+ await http.post('/auth/challenge', { publicKey: 'p' }, { skipAuth: true, retry: false });
67
+ const fresh = createJwt({ userId: 'u', exp: nowSec() + 3600, jti: 'fresh' });
68
+ http.setTokens(fresh);
69
+ return fresh;
70
+ });
71
+
72
+ expect(await settlesWithin(http.get('/data'), 500)).toBe(true);
73
+ });
74
+
75
+ it('a skipAuth public request does NOT await a pending refresh', async () => {
76
+ let releaseRefresh: (() => void) | undefined;
77
+ const refreshGate = new Promise<void>((resolve) => {
78
+ releaseRefresh = resolve;
79
+ });
80
+
81
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
82
+ const url = String(input);
83
+ if (url.includes('/auth/challenge')) return jsonResponse({ challenge: 'c' });
84
+ return jsonResponse({ ok: true });
85
+ }) as typeof fetch;
86
+
87
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
88
+ http.setTokens(createJwt({ userId: 'u', exp: nowSec() + 30 }));
89
+
90
+ http.setAuthRefreshHandler(async () => {
91
+ await refreshGate; // hold the refresh open
92
+ const fresh = createJwt({ userId: 'u', exp: nowSec() + 3600, jti: 'fresh' });
93
+ http.setTokens(fresh);
94
+ return fresh;
95
+ });
96
+
97
+ // A data request whose preflight starts (and blocks on) the held refresh.
98
+ const data = http.get('/data');
99
+ await new Promise((resolve) => setTimeout(resolve, 0));
100
+
101
+ // A skipAuth public request must settle WITHOUT waiting for the held refresh.
102
+ const pub = http.post('/auth/challenge', { publicKey: 'p' }, { skipAuth: true, retry: false });
103
+ expect(await settlesWithin(pub, 300)).toBe(true);
104
+
105
+ releaseRefresh?.();
106
+ await data;
107
+ });
108
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * RequestQueue deadlock regression.
3
+ *
4
+ * The auth preflight (`getAuthHeader` → single-flight `refreshAccessToken` → the
5
+ * device-secret mint) used to run INSIDE a RequestQueue slot, and the mint itself
6
+ * went through the queue. When `maxConcurrentRequests` requests all parked in
7
+ * slots awaiting that same mint, the mint could never acquire a slot to run —
8
+ * a systemic deadlock in which NOTHING settles (observed on a real device: the
9
+ * running count climbs to the pool size with zero settled requests, including a
10
+ * public GET /health). The fix: (1) `bypassQueue` runs control-plane calls (the
11
+ * mint) directly, and (2) the preflight is resolved OUTSIDE the slot, so an
12
+ * auth-blocked request never holds a slot while the shared mint runs.
13
+ */
14
+ import { HttpService } from '../HttpService';
15
+
16
+ function createJwt(payload: Record<string, unknown>): string {
17
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
18
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
19
+ }
20
+
21
+ function jsonResponse(data: unknown): Response {
22
+ return new Response(JSON.stringify({ data }), {
23
+ status: 200,
24
+ headers: { 'content-type': 'application/json' },
25
+ });
26
+ }
27
+
28
+ /** Resolve to `true` if `promise` settles within `ms`, else `false`. Always
29
+ * clears its timer so no pending timeout leaks into jest's teardown. */
30
+ async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
31
+ let timer: ReturnType<typeof setTimeout> | undefined;
32
+ const timeout = new Promise<false>((resolve) => {
33
+ timer = setTimeout(() => resolve(false), ms);
34
+ });
35
+ try {
36
+ return await Promise.race([promise.then(() => true), timeout]);
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+
42
+ describe('HttpService RequestQueue deadlock', () => {
43
+ const originalFetch = globalThis.fetch;
44
+
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch;
47
+ jest.restoreAllMocks();
48
+ });
49
+
50
+ it('runs a bypassQueue control-plane request even when every slot is held', async () => {
51
+ const releases: Array<() => void> = [];
52
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
53
+ const url = String(input);
54
+ if (url.includes('/slow')) {
55
+ // Occupy the slot indefinitely until explicitly released.
56
+ await new Promise<void>((resolve) => releases.push(resolve));
57
+ return jsonResponse({ slow: true });
58
+ }
59
+ return jsonResponse({ minted: true });
60
+ }) as typeof fetch;
61
+
62
+ const http = new HttpService({
63
+ baseURL: 'https://api.oxy.so',
64
+ maxConcurrentRequests: 2,
65
+ enableRetry: false,
66
+ });
67
+
68
+ // Saturate both slots with requests whose fetch never settles.
69
+ const slowA = http.get('/slow/a', { skipAuth: true });
70
+ const slowB = http.get('/slow/b', { skipAuth: true });
71
+ await new Promise((resolve) => setTimeout(resolve, 0));
72
+
73
+ // The device-secret mint (bypassQueue) must still run despite full saturation.
74
+ const mint = http.post(
75
+ '/session/device/token',
76
+ { deviceId: 'd', deviceSecret: 's' },
77
+ { skipAuth: true, bypassQueue: true, retry: false },
78
+ );
79
+
80
+ expect(await settlesWithin(mint, 500)).toBe(true);
81
+
82
+ releases.forEach((release) => release());
83
+ await Promise.all([slowA, slowB]);
84
+ });
85
+
86
+ it('does not deadlock when a boot burst larger than the pool all trigger the same preflight mint', async () => {
87
+ const now = Math.floor(Date.now() / 1000);
88
+ // Within the 60s refresh lead window → every request's preflight refreshes.
89
+ const nearExpiry = createJwt({ userId: 'u', exp: now + 30 });
90
+ const fresh = createJwt({ userId: 'u', exp: now + 3600, jti: 'fresh' });
91
+
92
+ let mintCalls = 0;
93
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
94
+ const url = String(input);
95
+ if (url.includes('/session/device/token')) {
96
+ mintCalls += 1;
97
+ return jsonResponse({ minted: true });
98
+ }
99
+ return jsonResponse({ ok: true });
100
+ }) as typeof fetch;
101
+
102
+ const http = new HttpService({
103
+ baseURL: 'https://api.oxy.so',
104
+ maxConcurrentRequests: 2,
105
+ enableRetry: false,
106
+ });
107
+ http.setTokens(nearExpiry);
108
+
109
+ http.setAuthRefreshHandler(async () => {
110
+ // Mirror refreshDeviceSecretArm: mint via a bypassQueue request through the
111
+ // SAME instance, then plant + return the fresh token.
112
+ await http.post(
113
+ '/session/device/token',
114
+ { deviceId: 'd', deviceSecret: 's' },
115
+ { skipAuth: true, bypassQueue: true, retry: false },
116
+ );
117
+ http.setTokens(fresh);
118
+ return fresh;
119
+ });
120
+
121
+ // A boot burst of 4 against a pool of 2, all in the near-expiry window.
122
+ const burst = Promise.all([http.get('/a'), http.get('/b'), http.get('/c'), http.get('/d')]);
123
+
124
+ expect(await settlesWithin(burst, 800)).toBe(true);
125
+ // The single-flight refresh coalesces the whole burst into ONE mint.
126
+ expect(mintCalls).toBe(1);
127
+ });
128
+ });
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "حساباتك"
117
+ },
118
+ "loading": "جارٍ تحميل الحسابات...",
115
119
  "qrHeadline": "سجّل الدخول بهوية Oxy الخاصة بك",
116
120
  "signInWithOxy": "تسجيل الدخول باستخدام Oxy",
117
121
  "useIdentityOnDevice": "لا تملك Commons؟ استخدم مفتاح مرور على هذا الجهاز بدلاً من ذلك",
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "Els teus comptes"
117
+ },
118
+ "loading": "S'estan carregant els comptes...",
115
119
  "qrHeadline": "Inicia sessió amb la teva identitat d'Oxy",
116
120
  "signInWithOxy": "Inicia sessió amb Oxy",
117
121
  "useIdentityOnDevice": "No tens Commons? Fes servir una clau d'accés en aquest dispositiu",
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "Ihre Konten"
117
+ },
118
+ "loading": "Konten werden geladen...",
115
119
  "qrHeadline": "Melden Sie sich mit Ihrer Oxy-Identität an",
116
120
  "signInWithOxy": "Mit Oxy anmelden",
117
121
  "useIdentityOnDevice": "Kein Commons? Verwenden Sie stattdessen einen Passkey auf diesem Gerät",
@@ -421,7 +421,8 @@
421
421
  "getCommons": "Get Commons",
422
422
  "commonsNotInstalled": "Don't have Commons? Get the app to sign in with your Oxy ID.",
423
423
  "showQrAnyway": "I have Commons on another device",
424
- "switchWhileSignedInAs": "Switch account, signed in as {{name}}"
424
+ "switchWhileSignedInAs": "Switch account, signed in as {{name}}",
425
+ "manageOnDevice": "Manage accounts on this device"
425
426
  },
426
427
  "reputation": {
427
428
  "faq": {
@@ -754,6 +755,8 @@
754
755
  "saving": "Saving…",
755
756
  "helper": "The cropped circle is what will appear on your profile. Pinch to zoom, drag to position.",
756
757
  "zoom": "{{value}}×",
758
+ "zoomIn": "Zoom in",
759
+ "zoomOut": "Zoom out",
757
760
  "a11yImage": "Crop preview. Pinch to zoom and drag to reposition the image.",
758
761
  "a11yReset": "Reset crop to default position",
759
762
  "a11yResetAnnouncement": "Crop reset"
@@ -774,6 +777,47 @@
774
777
  },
775
778
  "changeAvatar": "Change avatar"
776
779
  },
780
+ "changeAvatar": {
781
+ "title": "Change profile picture",
782
+ "sources": {
783
+ "upload": {
784
+ "title": "Upload from device",
785
+ "description": "Choose a photo from your gallery"
786
+ },
787
+ "camera": {
788
+ "title": "Take a picture",
789
+ "description": "Use your camera"
790
+ },
791
+ "files": {
792
+ "title": "My Oxy files",
793
+ "description": "Pick an image you already uploaded"
794
+ },
795
+ "remove": {
796
+ "title": "Remove current photo",
797
+ "description": "Go back to your initials"
798
+ }
799
+ },
800
+ "remove": {
801
+ "confirmTitle": "Remove profile picture?",
802
+ "confirmMessage": "Your profile will show your initials instead. You can add a new photo at any time.",
803
+ "confirmLabel": "Remove"
804
+ },
805
+ "permission": {
806
+ "libraryDenied": "Oxy needs access to your photos to upload a picture.",
807
+ "cameraDenied": "Oxy needs access to your camera to take a picture.",
808
+ "openSettings": "Open settings"
809
+ },
810
+ "errors": {
811
+ "pickerUnavailable": "Photo picking is not available on this device.",
812
+ "cameraUnavailable": "The camera is not available on this device.",
813
+ "pickFailed": "Could not open the photo picker.",
814
+ "cameraFailed": "Could not open the camera.",
815
+ "loadImageFailed": "Could not load the selected image."
816
+ },
817
+ "a11y": {
818
+ "currentPhoto": "Your current profile picture"
819
+ }
820
+ },
777
821
  "accountOverview": {
778
822
  "title": "Account",
779
823
  "sections": {
@@ -1442,6 +1486,11 @@
1442
1486
  "uploadPhotos": "Upload Photos",
1443
1487
  "uploadFiles": "Upload Files",
1444
1488
  "clearSearch": "Clear Search",
1489
+ "retry": "Try again",
1490
+ "loadError": {
1491
+ "title": "Couldn't load your files",
1492
+ "description": "Something went wrong. Check your connection and try again."
1493
+ },
1445
1494
  "choosePhoto": "Choose Photo",
1446
1495
  "done": "Done",
1447
1496
  "doneWithCount": "Done ({{count}})",
@@ -1614,7 +1663,9 @@
1614
1663
  },
1615
1664
  "accountMenu": {
1616
1665
  "label": "Account menu",
1617
- "manage": "Manage your Oxy Account",
1666
+ "manage": "Manage your Oxy account",
1667
+ "greeting": "Hi, {{name}}!",
1668
+ "switchAccount": "Switch account",
1618
1669
  "viewProfile": "View profile",
1619
1670
  "addAnother": "Add another account",
1620
1671
  "signOutAll": "Sign out of all accounts",
@@ -1622,7 +1673,22 @@
1622
1673
  "openHint": "Opens the account menu",
1623
1674
  "openWithUser": "Account menu for {{name}}",
1624
1675
  "switching": "Switching account…",
1625
- "signOutAccount": "Sign out {{name}}"
1676
+ "signOutAccount": "Sign out {{name}}",
1677
+ "storage": {
1678
+ "title": "Oxy storage",
1679
+ "usage": "{{used}} of {{total}} used",
1680
+ "used": "Used",
1681
+ "free": "Free",
1682
+ "unavailable": "Usage details unavailable",
1683
+ "upgrade": "Upgrade plan",
1684
+ "manage": "Manage storage"
1685
+ },
1686
+ "data": "Your data in Oxy",
1687
+ "settings": "Oxy settings",
1688
+ "help": "Help & feedback",
1689
+ "signOut": "Sign out",
1690
+ "privacy": "Privacy Policy",
1691
+ "terms": "Terms of Service"
1626
1692
  },
1627
1693
  "accounts": {
1628
1694
  "activeAccount": {
@@ -290,6 +290,8 @@
290
290
  "saving": "Guardando…",
291
291
  "helper": "El círculo recortado es lo que aparecerá en tu perfil. Pellizca para acercar, arrastra para colocar.",
292
292
  "zoom": "{{value}}×",
293
+ "zoomIn": "Acercar",
294
+ "zoomOut": "Alejar",
293
295
  "a11yImage": "Vista previa del recorte. Pellizca para acercar y arrastra para reposicionar la imagen.",
294
296
  "a11yReset": "Restablecer el recorte a la posición predeterminada",
295
297
  "a11yResetAnnouncement": "Recorte restablecido"
@@ -310,6 +312,47 @@
310
312
  },
311
313
  "changeAvatar": "Cambiar avatar"
312
314
  },
315
+ "changeAvatar": {
316
+ "title": "Cambiar foto de perfil",
317
+ "sources": {
318
+ "upload": {
319
+ "title": "Subir desde el dispositivo",
320
+ "description": "Elige una foto de tu galería"
321
+ },
322
+ "camera": {
323
+ "title": "Hacer una foto",
324
+ "description": "Usa tu cámara"
325
+ },
326
+ "files": {
327
+ "title": "Mis archivos de Oxy",
328
+ "description": "Elige una imagen que ya has subido"
329
+ },
330
+ "remove": {
331
+ "title": "Eliminar la foto actual",
332
+ "description": "Vuelve a tus iniciales"
333
+ }
334
+ },
335
+ "remove": {
336
+ "confirmTitle": "¿Eliminar la foto de perfil?",
337
+ "confirmMessage": "Tu perfil mostrará tus iniciales. Puedes añadir una foto nueva cuando quieras.",
338
+ "confirmLabel": "Eliminar"
339
+ },
340
+ "permission": {
341
+ "libraryDenied": "Oxy necesita acceso a tus fotos para subir una imagen.",
342
+ "cameraDenied": "Oxy necesita acceso a tu cámara para hacer una foto.",
343
+ "openSettings": "Abrir ajustes"
344
+ },
345
+ "errors": {
346
+ "pickerUnavailable": "La selección de fotos no está disponible en este dispositivo.",
347
+ "cameraUnavailable": "La cámara no está disponible en este dispositivo.",
348
+ "pickFailed": "No se pudo abrir el selector de fotos.",
349
+ "cameraFailed": "No se pudo abrir la cámara.",
350
+ "loadImageFailed": "No se pudo cargar la imagen seleccionada."
351
+ },
352
+ "a11y": {
353
+ "currentPhoto": "Tu foto de perfil actual"
354
+ }
355
+ },
313
356
  "common": {
314
357
  "actions": {
315
358
  "back": "Atrás",
@@ -619,7 +662,8 @@
619
662
  "getCommons": "Descargar Commons",
620
663
  "commonsNotInstalled": "¿No tienes Commons? Descarga la app para iniciar sesión con tu Oxy ID.",
621
664
  "showQrAnyway": "Tengo Commons en otro dispositivo",
622
- "switchWhileSignedInAs": "Cambiar de cuenta, con la sesión iniciada como {{name}}"
665
+ "switchWhileSignedInAs": "Cambiar de cuenta, con la sesión iniciada como {{name}}",
666
+ "manageOnDevice": "Gestionar cuentas en este dispositivo"
623
667
  },
624
668
  "feedback": {
625
669
  "type": {
@@ -1442,6 +1486,11 @@
1442
1486
  "uploadPhotos": "Subir fotos",
1443
1487
  "uploadFiles": "Subir archivos",
1444
1488
  "clearSearch": "Limpiar búsqueda",
1489
+ "retry": "Reintentar",
1490
+ "loadError": {
1491
+ "title": "No se pudieron cargar tus archivos",
1492
+ "description": "Algo salió mal. Comprueba tu conexión e inténtalo de nuevo."
1493
+ },
1445
1494
  "choosePhoto": "Elegir foto",
1446
1495
  "done": "Listo",
1447
1496
  "doneWithCount": "Listo ({{count}})",
@@ -1615,6 +1664,8 @@
1615
1664
  "accountMenu": {
1616
1665
  "label": "Menú de cuenta",
1617
1666
  "manage": "Gestiona tu cuenta de Oxy",
1667
+ "greeting": "¡Hola, {{name}}!",
1668
+ "switchAccount": "Cambiar de cuenta",
1618
1669
  "viewProfile": "Ver perfil",
1619
1670
  "addAnother": "Añadir otra cuenta",
1620
1671
  "signOutAll": "Cerrar sesión en todas las cuentas",
@@ -1622,7 +1673,22 @@
1622
1673
  "openHint": "Abre el menú de cuenta",
1623
1674
  "openWithUser": "Menú de cuenta de {{name}}",
1624
1675
  "switching": "Cambiando de cuenta…",
1625
- "signOutAccount": "Cerrar sesión de {{name}}"
1676
+ "signOutAccount": "Cerrar sesión de {{name}}",
1677
+ "storage": {
1678
+ "title": "Almacenamiento de Oxy",
1679
+ "usage": "{{used}} de {{total}} en uso",
1680
+ "used": "Usado",
1681
+ "free": "Libre",
1682
+ "unavailable": "Detalles de uso no disponibles",
1683
+ "upgrade": "Mejorar plan",
1684
+ "manage": "Gestionar almacenamiento"
1685
+ },
1686
+ "data": "Tus datos en Oxy",
1687
+ "settings": "Ajustes de Oxy",
1688
+ "help": "Ayuda y comentarios",
1689
+ "signOut": "Cerrar sesión",
1690
+ "privacy": "Política de privacidad",
1691
+ "terms": "Términos del servicio"
1626
1692
  },
1627
1693
  "accounts": {
1628
1694
  "activeAccount": {
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "Vos comptes"
117
+ },
118
+ "loading": "Chargement des comptes...",
115
119
  "qrHeadline": "Connectez-vous avec votre identité Oxy",
116
120
  "signInWithOxy": "Se connecter avec Oxy",
117
121
  "useIdentityOnDevice": "Pas de Commons ? Utilisez une clé d'accès sur cet appareil",
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "I tuoi account"
117
+ },
118
+ "loading": "Caricamento account...",
115
119
  "qrHeadline": "Accedi con la tua identità Oxy",
116
120
  "signInWithOxy": "Accedi con Oxy",
117
121
  "useIdentityOnDevice": "Non hai Commons? Usa una passkey su questo dispositivo",
@@ -112,6 +112,10 @@
112
112
  }
113
113
  },
114
114
  "accountSwitcher": {
115
+ "sections": {
116
+ "yourAccounts": "あなたのアカウント"
117
+ },
118
+ "loading": "アカウントを読み込み中...",
115
119
  "qrHeadline": "Oxy IDでサインイン",
116
120
  "signInWithOxy": "Oxyでサインイン",
117
121
  "useIdentityOnDevice": "Commonsがありませんか?このデバイスのパスキーを使用",