@oxyhq/core 1.11.11 → 1.11.13
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/CrossDomainAuth.js +3 -1
- package/dist/cjs/HttpService.js +227 -51
- package/dist/cjs/OxyServices.base.js +9 -0
- package/dist/cjs/OxyServices.js +8 -3
- package/dist/cjs/crypto/index.js +3 -1
- package/dist/cjs/crypto/keyManager.js +476 -172
- package/dist/cjs/crypto/polyfill.js +14 -65
- package/dist/cjs/crypto/recoveryPhrase.js +30 -11
- package/dist/cjs/crypto/signatureService.js +25 -60
- package/dist/cjs/i18n/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/es-ES.json +46 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/mixins/OxyServices.assets.js +9 -4
- package/dist/cjs/mixins/OxyServices.auth.js +27 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.features.js +0 -11
- package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
- package/dist/cjs/mixins/OxyServices.language.js +5 -36
- package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
- package/dist/cjs/mixins/OxyServices.security.js +13 -2
- package/dist/cjs/mixins/OxyServices.user.js +70 -38
- package/dist/cjs/mixins/OxyServices.utility.js +19 -43
- package/dist/cjs/mixins/index.js +11 -3
- package/dist/cjs/utils/accountUtils.js +71 -2
- package/dist/cjs/utils/asyncUtils.js +34 -5
- package/dist/cjs/utils/deviceManager.js +5 -36
- package/dist/cjs/utils/platformCrypto.js +165 -0
- package/dist/cjs/utils/platformCrypto.native.js +123 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +3 -1
- package/dist/esm/HttpService.js +228 -52
- package/dist/esm/OxyServices.base.js +9 -0
- package/dist/esm/OxyServices.js +8 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +473 -138
- package/dist/esm/crypto/polyfill.js +14 -32
- package/dist/esm/crypto/recoveryPhrase.js +30 -11
- package/dist/esm/crypto/signatureService.js +25 -27
- package/dist/esm/i18n/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/es-ES.json +46 -1
- package/dist/esm/i18n/locales/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/mixins/OxyServices.assets.js +9 -4
- package/dist/esm/mixins/OxyServices.auth.js +27 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.features.js +0 -11
- package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
- package/dist/esm/mixins/OxyServices.language.js +5 -3
- package/dist/esm/mixins/OxyServices.redirect.js +6 -2
- package/dist/esm/mixins/OxyServices.security.js +13 -2
- package/dist/esm/mixins/OxyServices.user.js +70 -38
- package/dist/esm/mixins/OxyServices.utility.js +19 -10
- package/dist/esm/mixins/index.js +11 -3
- package/dist/esm/utils/accountUtils.js +67 -1
- package/dist/esm/utils/asyncUtils.js +34 -5
- package/dist/esm/utils/deviceManager.js +5 -3
- package/dist/esm/utils/platformCrypto.js +125 -0
- package/dist/esm/utils/platformCrypto.native.js +80 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +47 -3
- package/dist/types/OxyServices.base.d.ts +7 -0
- package/dist/types/OxyServices.d.ts +36 -3
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +110 -9
- package/dist/types/crypto/polyfill.d.ts +3 -1
- package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
- package/dist/types/crypto/signatureService.d.ts +4 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
- package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -7
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +40 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/mixins/index.d.ts +52 -4
- package/dist/types/models/interfaces.d.ts +62 -3
- package/dist/types/utils/accountUtils.d.ts +41 -1
- package/dist/types/utils/asyncUtils.d.ts +6 -2
- package/dist/types/utils/platformCrypto.d.ts +87 -0
- package/dist/types/utils/platformCrypto.native.d.ts +54 -0
- package/package.json +28 -1
- package/src/CrossDomainAuth.ts +12 -10
- package/src/HttpService.ts +264 -51
- package/src/OxyServices.base.ts +10 -0
- package/src/OxyServices.ts +9 -4
- package/src/crypto/__tests__/keyManager.test.ts +336 -0
- package/src/crypto/index.ts +6 -1
- package/src/crypto/keyManager.ts +529 -151
- package/src/crypto/polyfill.ts +14 -34
- package/src/crypto/recoveryPhrase.ts +56 -17
- package/src/crypto/signatureService.ts +25 -29
- package/src/i18n/locales/en-US.json +46 -1
- package/src/i18n/locales/es-ES.json +46 -1
- package/src/index.ts +16 -3
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.auth.ts +28 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.features.ts +2 -12
- package/src/mixins/OxyServices.fedcm.ts +4 -3
- package/src/mixins/OxyServices.language.ts +6 -4
- package/src/mixins/OxyServices.redirect.ts +6 -2
- package/src/mixins/OxyServices.security.ts +18 -8
- package/src/mixins/OxyServices.user.ts +90 -49
- package/src/mixins/OxyServices.utility.ts +19 -10
- package/src/mixins/index.ts +58 -7
- package/src/models/interfaces.ts +65 -3
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/accountUtils.ts +82 -2
- package/src/utils/asyncUtils.ts +39 -9
- package/src/utils/deviceManager.ts +7 -4
- package/src/utils/platformCrypto.native.ts +101 -0
- package/src/utils/platformCrypto.ts +145 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { retryAsync } from '../asyncUtils';
|
|
2
|
+
import { handleHttpError } from '../errorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression coverage for the 1.11.11 retry storm:
|
|
6
|
+
*
|
|
7
|
+
* HttpService wraps fetch errors through handleHttpError before rethrowing.
|
|
8
|
+
* handleHttpError returns a flat ApiError ({ message, code, status }) without
|
|
9
|
+
* a nested `.response` field. Prior to the fix, retryAsync's default
|
|
10
|
+
* shouldRetry predicate only inspected `error.response.status`, so every 4xx
|
|
11
|
+
* response was treated as retryable. That turned ~5ms 404 lookups into 8-10s
|
|
12
|
+
* stalls because every Mention endpoint hitting Oxy for a missing
|
|
13
|
+
* user/topic hit the full retry+backoff schedule.
|
|
14
|
+
*
|
|
15
|
+
* These tests lock the fix in place: both the nested and flat shapes MUST
|
|
16
|
+
* short-circuit retries for 4xx, and 5xx/network errors MUST still retry.
|
|
17
|
+
*/
|
|
18
|
+
describe('retryAsync default shouldRetry predicate', () => {
|
|
19
|
+
it('does not retry on a flat ApiError-shaped 404 (handleHttpError output)', async () => {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
const apiError = { message: 'Not found', code: 'NOT_FOUND', status: 404 };
|
|
23
|
+
|
|
24
|
+
await expect(
|
|
25
|
+
retryAsync(async () => {
|
|
26
|
+
attempts++;
|
|
27
|
+
throw apiError;
|
|
28
|
+
}, 3, 50)
|
|
29
|
+
).rejects.toBe(apiError);
|
|
30
|
+
|
|
31
|
+
expect(attempts).toBe(1);
|
|
32
|
+
// Sanity: we should NOT have slept through any backoff windows.
|
|
33
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not retry on an axios-style nested 404 (response.status)', async () => {
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
const axiosError = {
|
|
39
|
+
message: 'Not found',
|
|
40
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
retryAsync(async () => {
|
|
45
|
+
attempts++;
|
|
46
|
+
throw axiosError;
|
|
47
|
+
}, 3, 50)
|
|
48
|
+
).rejects.toBe(axiosError);
|
|
49
|
+
|
|
50
|
+
expect(attempts).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not retry on any 4xx flat-shape (400/401/403/422)', async () => {
|
|
54
|
+
for (const status of [400, 401, 403, 422]) {
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
await expect(
|
|
57
|
+
retryAsync(async () => {
|
|
58
|
+
attempts++;
|
|
59
|
+
throw { message: 'client', code: 'X', status };
|
|
60
|
+
}, 2, 10)
|
|
61
|
+
).rejects.toBeDefined();
|
|
62
|
+
expect(attempts).toBe(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('retries on flat-shape 500 errors until maxRetries', async () => {
|
|
67
|
+
let attempts = 0;
|
|
68
|
+
await expect(
|
|
69
|
+
retryAsync(async () => {
|
|
70
|
+
attempts++;
|
|
71
|
+
throw { message: 'boom', code: 'INTERNAL_ERROR', status: 500 };
|
|
72
|
+
}, 2, 1)
|
|
73
|
+
).rejects.toBeDefined();
|
|
74
|
+
expect(attempts).toBe(3); // initial + 2 retries
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('retries on nested-shape 503 errors until maxRetries', async () => {
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
await expect(
|
|
80
|
+
retryAsync(async () => {
|
|
81
|
+
attempts++;
|
|
82
|
+
throw { message: 'unavailable', response: { status: 503 } };
|
|
83
|
+
}, 2, 1)
|
|
84
|
+
).rejects.toBeDefined();
|
|
85
|
+
expect(attempts).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('retries on network-style errors without any status (TypeError)', async () => {
|
|
89
|
+
let attempts = 0;
|
|
90
|
+
await expect(
|
|
91
|
+
retryAsync(async () => {
|
|
92
|
+
attempts++;
|
|
93
|
+
throw new TypeError('Failed to fetch');
|
|
94
|
+
}, 2, 1)
|
|
95
|
+
).rejects.toBeDefined();
|
|
96
|
+
expect(attempts).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns the successful result without extra attempts', async () => {
|
|
100
|
+
let attempts = 0;
|
|
101
|
+
const result = await retryAsync(async () => {
|
|
102
|
+
attempts++;
|
|
103
|
+
return 'ok' as const;
|
|
104
|
+
}, 3, 1);
|
|
105
|
+
expect(result).toBe('ok');
|
|
106
|
+
expect(attempts).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('recovers after a transient 5xx followed by success', async () => {
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
const result = await retryAsync(async () => {
|
|
112
|
+
attempts++;
|
|
113
|
+
if (attempts < 2) {
|
|
114
|
+
throw { message: 'transient', status: 502 };
|
|
115
|
+
}
|
|
116
|
+
return 'recovered' as const;
|
|
117
|
+
}, 3, 1);
|
|
118
|
+
expect(result).toBe('recovered');
|
|
119
|
+
expect(attempts).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('honours a custom shouldRetry predicate even when default would retry', async () => {
|
|
123
|
+
let attempts = 0;
|
|
124
|
+
await expect(
|
|
125
|
+
retryAsync(
|
|
126
|
+
async () => {
|
|
127
|
+
attempts++;
|
|
128
|
+
throw { message: 'nope', status: 500 };
|
|
129
|
+
},
|
|
130
|
+
5,
|
|
131
|
+
1,
|
|
132
|
+
() => false
|
|
133
|
+
)
|
|
134
|
+
).rejects.toBeDefined();
|
|
135
|
+
expect(attempts).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ignores non-numeric status fields instead of treating them as 4xx', async () => {
|
|
139
|
+
let attempts = 0;
|
|
140
|
+
await expect(
|
|
141
|
+
retryAsync(async () => {
|
|
142
|
+
attempts++;
|
|
143
|
+
throw { message: 'weird', status: 'oops' as unknown as number };
|
|
144
|
+
}, 2, 1)
|
|
145
|
+
).rejects.toBeDefined();
|
|
146
|
+
// Non-numeric status must NOT be interpreted as 4xx — should retry normally.
|
|
147
|
+
expect(attempts).toBe(3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* handleHttpError is the wire between fetch-thrown errors and retryAsync.
|
|
153
|
+
* Lock in that it exposes the HTTP status at the top level so the retry
|
|
154
|
+
* predicate above can see it.
|
|
155
|
+
*/
|
|
156
|
+
describe('handleHttpError preserves HTTP status for retry predicates', () => {
|
|
157
|
+
it('flattens a fetch-style error with .response.status into ApiError.status', () => {
|
|
158
|
+
const fetchError = Object.assign(new Error('Not found'), {
|
|
159
|
+
status: 404,
|
|
160
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
161
|
+
});
|
|
162
|
+
const result = handleHttpError(fetchError);
|
|
163
|
+
expect(result.status).toBe(404);
|
|
164
|
+
expect(result.code).toBe('NOT_FOUND');
|
|
165
|
+
expect(result.message).toBe('Not found');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('preserves 401 status from fetch errors', () => {
|
|
169
|
+
const fetchError = Object.assign(new Error('Unauthorized'), {
|
|
170
|
+
status: 401,
|
|
171
|
+
response: { status: 401, statusText: 'Unauthorized' },
|
|
172
|
+
});
|
|
173
|
+
const result = handleHttpError(fetchError);
|
|
174
|
+
expect(result.status).toBe(401);
|
|
175
|
+
expect(result.code).toBe('UNAUTHORIZED');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('maps 500 to INTERNAL_ERROR with status preserved', () => {
|
|
179
|
+
const fetchError = Object.assign(new Error('boom'), {
|
|
180
|
+
status: 500,
|
|
181
|
+
response: { status: 500, statusText: 'Internal Server Error' },
|
|
182
|
+
});
|
|
183
|
+
const result = handleHttpError(fetchError);
|
|
184
|
+
expect(result.status).toBe(500);
|
|
185
|
+
expect(result.code).toBe('INTERNAL_ERROR');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Used by both @oxyhq/services (React Native) and @oxyhq/auth (Web) account stores.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { translate } from '../i18n';
|
|
7
|
+
|
|
6
8
|
export interface QuickAccount {
|
|
7
9
|
sessionId: string;
|
|
8
10
|
userId?: string;
|
|
@@ -12,6 +14,83 @@ export interface QuickAccount {
|
|
|
12
14
|
avatarUrl?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/** Minimal user shape accepted by display-name helpers. Avoids importing the full User type. */
|
|
18
|
+
export interface DisplayNameUserShape {
|
|
19
|
+
name?: string | { first?: string; last?: string; full?: string; [key: string]: unknown };
|
|
20
|
+
username?: string;
|
|
21
|
+
publicKey?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Truncate a long public key for display, e.g. `0x12345678…`.
|
|
26
|
+
* Falls back to the raw key if it's too short to truncate.
|
|
27
|
+
*/
|
|
28
|
+
export const formatPublicKeyHandle = (publicKey: string): string => {
|
|
29
|
+
const cleaned = publicKey.startsWith('0x') ? publicKey.slice(2) : publicKey;
|
|
30
|
+
if (cleaned.length <= 8) return `0x${cleaned}`;
|
|
31
|
+
return `0x${cleaned.slice(0, 8)}…`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a friendly display name for a user.
|
|
36
|
+
*
|
|
37
|
+
* Order of preference:
|
|
38
|
+
* 1. `name.full`, or composed `name.first name.last`
|
|
39
|
+
* 2. `name` (when stored as a plain string)
|
|
40
|
+
* 3. `username`
|
|
41
|
+
* 4. `Account 0x12345678…` (derived from publicKey, when present)
|
|
42
|
+
* 5. Translated fallback (e.g. "Unnamed")
|
|
43
|
+
*
|
|
44
|
+
* The translation key `common.unnamed` is used for the final fallback. If the
|
|
45
|
+
* caller does not pass a locale, the default English translation is used.
|
|
46
|
+
*/
|
|
47
|
+
export const getAccountDisplayName = (
|
|
48
|
+
user: DisplayNameUserShape | null | undefined,
|
|
49
|
+
locale?: string,
|
|
50
|
+
): string => {
|
|
51
|
+
if (!user) return translate(locale, 'common.unnamed');
|
|
52
|
+
|
|
53
|
+
const { name, username, publicKey } = user;
|
|
54
|
+
|
|
55
|
+
if (name && typeof name === 'object') {
|
|
56
|
+
if (typeof name.full === 'string' && name.full.trim()) return name.full.trim();
|
|
57
|
+
const first = typeof name.first === 'string' ? name.first.trim() : '';
|
|
58
|
+
const last = typeof name.last === 'string' ? name.last.trim() : '';
|
|
59
|
+
const composed = [first, last].filter(Boolean).join(' ').trim();
|
|
60
|
+
if (composed) return composed;
|
|
61
|
+
} else if (typeof name === 'string' && name.trim()) {
|
|
62
|
+
return name.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof username === 'string' && username.trim()) return username.trim();
|
|
66
|
+
|
|
67
|
+
if (typeof publicKey === 'string' && publicKey.length > 0) {
|
|
68
|
+
return translate(locale, 'common.accountFallback', {
|
|
69
|
+
handle: formatPublicKeyHandle(publicKey),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return translate(locale, 'common.unnamed');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a `@handle` style identifier for a user.
|
|
78
|
+
*
|
|
79
|
+
* Returns the bare username when present (without the `@`), otherwise a
|
|
80
|
+
* truncated public-key handle (`0x12345678…`), or `undefined` when neither is
|
|
81
|
+
* available — callers can decide whether to hide the line entirely.
|
|
82
|
+
*/
|
|
83
|
+
export const getAccountFallbackHandle = (
|
|
84
|
+
user: DisplayNameUserShape | null | undefined,
|
|
85
|
+
): string | undefined => {
|
|
86
|
+
if (!user) return undefined;
|
|
87
|
+
if (typeof user.username === 'string' && user.username.trim()) return user.username.trim();
|
|
88
|
+
if (typeof user.publicKey === 'string' && user.publicKey.length > 0) {
|
|
89
|
+
return formatPublicKeyHandle(user.publicKey);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
15
94
|
/**
|
|
16
95
|
* Build an ordered array of QuickAccounts from a map and order list.
|
|
17
96
|
*/
|
|
@@ -38,8 +117,9 @@ export const buildAccountsArray = (
|
|
|
38
117
|
export const createQuickAccount = (
|
|
39
118
|
sessionId: string,
|
|
40
119
|
userData: {
|
|
41
|
-
name?: { full?: string; first?: string };
|
|
120
|
+
name?: string | { full?: string; first?: string; last?: string };
|
|
42
121
|
username?: string;
|
|
122
|
+
publicKey?: string;
|
|
43
123
|
id?: string;
|
|
44
124
|
_id?: { toString(): string } | string;
|
|
45
125
|
avatar?: string;
|
|
@@ -47,7 +127,7 @@ export const createQuickAccount = (
|
|
|
47
127
|
existingAccount?: QuickAccount,
|
|
48
128
|
getFileDownloadUrl?: (fileId: string, variant: string) => string
|
|
49
129
|
): QuickAccount => {
|
|
50
|
-
const displayName = userData
|
|
130
|
+
const displayName = getAccountDisplayName(userData);
|
|
51
131
|
const userId = userData.id || (typeof userData._id === 'string' ? userData._id : userData._id?.toString());
|
|
52
132
|
|
|
53
133
|
// Preserve existing avatarUrl if avatar hasn't changed (prevents image reload)
|
package/src/utils/asyncUtils.ts
CHANGED
|
@@ -47,11 +47,38 @@ export async function parallelWithErrorHandling<T>(
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Extract an HTTP status code from an error value, tolerating both the
|
|
52
|
+
* axios-style nested shape (`error.response.status`) and the flat shape
|
|
53
|
+
* produced by {@link handleHttpError} / fetch-based clients (`error.status`).
|
|
54
|
+
*
|
|
55
|
+
* Centralising this lookup prevents retry predicates from silently falling
|
|
56
|
+
* through when one of the two shapes is missing, which previously caused
|
|
57
|
+
* @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
|
|
58
|
+
* multi-second stalls for every missing-resource lookup.
|
|
59
|
+
*/
|
|
60
|
+
function extractHttpStatus(error: unknown): number | undefined {
|
|
61
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
62
|
+
const candidate = error as {
|
|
63
|
+
status?: unknown;
|
|
64
|
+
response?: { status?: unknown } | null;
|
|
65
|
+
};
|
|
66
|
+
const flat = candidate.status;
|
|
67
|
+
if (typeof flat === 'number' && Number.isFinite(flat)) return flat;
|
|
68
|
+
const nested = candidate.response?.status;
|
|
69
|
+
if (typeof nested === 'number' && Number.isFinite(nested)) return nested;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/**
|
|
51
74
|
* Retry an async operation with exponential backoff
|
|
52
|
-
*
|
|
53
|
-
* By default, does not retry on 4xx errors (client errors).
|
|
54
|
-
*
|
|
75
|
+
*
|
|
76
|
+
* By default, does not retry on 4xx errors (client errors). The default
|
|
77
|
+
* predicate accepts both the axios-style `error.response.status` and the
|
|
78
|
+
* flat `error.status` shape produced by {@link handleHttpError}, so callers
|
|
79
|
+
* never accidentally retry a deterministic client failure.
|
|
80
|
+
*
|
|
81
|
+
* Use the `shouldRetry` callback to customize retry behavior.
|
|
55
82
|
*/
|
|
56
83
|
export async function retryAsync<T>(
|
|
57
84
|
operation: () => Promise<T>,
|
|
@@ -60,16 +87,19 @@ export async function retryAsync<T>(
|
|
|
60
87
|
shouldRetry?: (error: any) => boolean
|
|
61
88
|
): Promise<T> {
|
|
62
89
|
let lastError: any;
|
|
63
|
-
|
|
64
|
-
// Default shouldRetry: don't retry on 4xx errors
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
|
|
91
|
+
// Default shouldRetry: don't retry on 4xx errors (client errors).
|
|
92
|
+
// Checks BOTH `error.status` (flat shape from handleHttpError / fetch
|
|
93
|
+
// clients) AND `error.response.status` (axios-style shape) so neither
|
|
94
|
+
// representation can leak a client error into the retry loop.
|
|
95
|
+
const defaultShouldRetry = (error: unknown): boolean => {
|
|
96
|
+
const status = extractHttpStatus(error);
|
|
97
|
+
if (status !== undefined && status >= 400 && status < 500) {
|
|
68
98
|
return false;
|
|
69
99
|
}
|
|
70
100
|
return true;
|
|
71
101
|
};
|
|
72
|
-
|
|
102
|
+
|
|
73
103
|
const retryCheck = shouldRetry || defaultShouldRetry;
|
|
74
104
|
|
|
75
105
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadAsyncStorage } from './platformCrypto';
|
|
2
|
+
|
|
1
3
|
export interface DeviceFingerprint {
|
|
2
4
|
userAgent: string;
|
|
3
5
|
platform: string;
|
|
@@ -42,10 +44,11 @@ export class DeviceManager {
|
|
|
42
44
|
}> {
|
|
43
45
|
if (this.isReactNative()) {
|
|
44
46
|
try {
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
47
|
+
// `loadAsyncStorage` is per-platform: the RN variant statically imports
|
|
48
|
+
// @react-native-async-storage/async-storage, the default variant throws
|
|
49
|
+
// (never called outside RN because of the `isReactNative()` gate above).
|
|
50
|
+
const asyncStorageModule = await loadAsyncStorage();
|
|
51
|
+
const storage = asyncStorageModule.default;
|
|
49
52
|
return {
|
|
50
53
|
getItem: storage.getItem.bind(storage),
|
|
51
54
|
setItem: storage.setItem.bind(storage),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Crypto / Storage — React Native Variant
|
|
3
|
+
*
|
|
4
|
+
* Companion to `./platformCrypto.ts`. See the doc-comment at the top of that
|
|
5
|
+
* file for the full design.
|
|
6
|
+
*
|
|
7
|
+
* Metro auto-selects this file in any non-web build (`preferNativePlatform`
|
|
8
|
+
* is `true` for iOS / Android, so `*.native.js` shadows `*.js` during
|
|
9
|
+
* source-extension resolution inside `node_modules/@oxyhq/core/dist/`). On
|
|
10
|
+
* iOS / Android `<base>.ios.js` / `<base>.android.js` would shadow this file
|
|
11
|
+
* if they existed, but they don't — `.native.js` is the shared RN variant.
|
|
12
|
+
*
|
|
13
|
+
* - The default variant references Node's `'crypto'` and would crash Metro
|
|
14
|
+
* if bundled into an RN app.
|
|
15
|
+
* - This variant references the RN-only modules (`expo-crypto`,
|
|
16
|
+
* `expo-secure-store`, `@react-native-async-storage/async-storage`)
|
|
17
|
+
* as static imports, so Metro and Hermes both resolve and parse them
|
|
18
|
+
* cleanly.
|
|
19
|
+
*
|
|
20
|
+
* Both variants expose the same surface; importers don't care which one
|
|
21
|
+
* they got.
|
|
22
|
+
*
|
|
23
|
+
* # Why static imports?
|
|
24
|
+
*
|
|
25
|
+
* Every RN consumer of `@oxyhq/core` already lists or transitively pulls
|
|
26
|
+
* in `expo-crypto`, `expo-secure-store`, and
|
|
27
|
+
* `@react-native-async-storage/async-storage` (they're stable Expo modules
|
|
28
|
+
* present in `services`, `accounts`, `inbox`, and `test-app`). A static
|
|
29
|
+
* import is what Metro wants to see anyway, and Hermes parses it like any
|
|
30
|
+
* other ES module — no `Function`-constructor parser exotic-mode involved.
|
|
31
|
+
*
|
|
32
|
+
* This is also clearer to debug: Metro fails up-front with a normal
|
|
33
|
+
* unresolved-module error if a consumer is missing a peer dep, instead of
|
|
34
|
+
* a confusing runtime throw the first time a code path that needs the
|
|
35
|
+
* module is exercised.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as ExpoCrypto from 'expo-crypto';
|
|
39
|
+
import * as SecureStore from 'expo-secure-store';
|
|
40
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Node `crypto` — never available in RN.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export async function loadNodeCrypto(): Promise<typeof import('crypto')> {
|
|
47
|
+
// Unreachable in practice: every caller gates with `isNodeJS()` before
|
|
48
|
+
// invoking this. If it somehow does fire, throw immediately with a clear
|
|
49
|
+
// diagnostic rather than letting Metro / Hermes attempt to find a
|
|
50
|
+
// non-existent module at runtime.
|
|
51
|
+
throw new Error(
|
|
52
|
+
"[oxy.platformCrypto] Node's built-in 'crypto' module is not available " +
|
|
53
|
+
'in a React Native runtime. Use the RN-specific helpers ' +
|
|
54
|
+
'(loadExpoCrypto, getRandomBytesRN) or the Web Crypto API (`globalThis.crypto`).',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// expo-crypto — RN cryptographic primitives.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export async function loadExpoCrypto(): Promise<typeof import('expo-crypto')> {
|
|
63
|
+
return ExpoCrypto;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// expo-secure-store — RN keychain / keystore.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export async function loadSecureStore(): Promise<typeof import('expo-secure-store')> {
|
|
71
|
+
return SecureStore;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// @react-native-async-storage/async-storage — RN persistent KV storage.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
type AsyncStorageLike = {
|
|
79
|
+
getItem: (key: string) => Promise<string | null>;
|
|
80
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
81
|
+
removeItem: (key: string) => Promise<void>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export async function loadAsyncStorage(): Promise<{ default: AsyncStorageLike }> {
|
|
85
|
+
// Mirror the shape callers historically used (`module.default.<method>`)
|
|
86
|
+
// so the call sites don't have to know whether the underlying module
|
|
87
|
+
// ships ESM or CJS-with-default.
|
|
88
|
+
const storage = AsyncStorage as unknown as AsyncStorageLike;
|
|
89
|
+
return {
|
|
90
|
+
default: storage,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Synchronous random-bytes via `expo-crypto.getRandomBytes`. Available
|
|
96
|
+
* synchronously because `expo-crypto` is statically imported by this file
|
|
97
|
+
* — no async initialization race.
|
|
98
|
+
*/
|
|
99
|
+
export function getRandomBytesRN(byteCount: number): Uint8Array {
|
|
100
|
+
return ExpoCrypto.getRandomBytes(byteCount);
|
|
101
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Crypto / Storage — Default Variant (Node.js, Browser, generic bundlers)
|
|
3
|
+
*
|
|
4
|
+
* Provides lazy access to platform-specific crypto and storage modules.
|
|
5
|
+
*
|
|
6
|
+
* # Variants
|
|
7
|
+
*
|
|
8
|
+
* This module ships in two physical variants on disk, selected per consumer
|
|
9
|
+
* by the bundler / runtime:
|
|
10
|
+
*
|
|
11
|
+
* - `platformCrypto.js` — this file. Used by Node.js, Vite, webpack,
|
|
12
|
+
* Rollup, esbuild, and anything that does
|
|
13
|
+
* not match Metro's `*.native.js`
|
|
14
|
+
* source-extension preference.
|
|
15
|
+
* - `platformCrypto.native.js` — sibling file. Picked up automatically by
|
|
16
|
+
* Metro's resolver (which prefers
|
|
17
|
+
* `*.<platform>.js` and `*.native.js` over
|
|
18
|
+
* plain `*.js` when `preferNativePlatform`
|
|
19
|
+
* is true — Expo sets this for all non-web
|
|
20
|
+
* builds).
|
|
21
|
+
*
|
|
22
|
+
* The `package.json#exports` map also declares a `"react-native"` condition
|
|
23
|
+
* pointing at the same `dist/esm/index.js` entry — that entry transitively
|
|
24
|
+
* imports `./platformCrypto`, and Metro's per-file source-extension lookup
|
|
25
|
+
* substitutes the `.native.js` sibling automatically inside `dist/`. This
|
|
26
|
+
* means consumers never have to add resolver shims; Metro Just Works.
|
|
27
|
+
*
|
|
28
|
+
* Both variants expose the EXACT same public API; importers don't need to know
|
|
29
|
+
* which one they got. The variant difference is purely about which underlying
|
|
30
|
+
* native modules each one references:
|
|
31
|
+
*
|
|
32
|
+
* ┌──────────────────┬───────────────────────┬───────────────────────────────┐
|
|
33
|
+
* │ Function │ Default variant │ React Native variant │
|
|
34
|
+
* ├──────────────────┼───────────────────────┼───────────────────────────────┤
|
|
35
|
+
* │ loadNodeCrypto │ `await import('crypto')` (Node built-in) │
|
|
36
|
+
* │ │ │ throws — Node crypto is not │
|
|
37
|
+
* │ │ │ available on Hermes/RN │
|
|
38
|
+
* ├──────────────────┼───────────────────────┼───────────────────────────────┤
|
|
39
|
+
* │ loadExpoCrypto │ throws — expo-crypto │ static `import 'expo-crypto'` │
|
|
40
|
+
* │ │ is not part of a │ │
|
|
41
|
+
* │ │ Node/Vite bundle │ │
|
|
42
|
+
* ├──────────────────┼───────────────────────┼───────────────────────────────┤
|
|
43
|
+
* │ loadSecureStore │ throws (web/Node have │ static `import 'expo-secure-` │
|
|
44
|
+
* │ │ their own storage) │ store' │
|
|
45
|
+
* ├──────────────────┼───────────────────────┼───────────────────────────────┤
|
|
46
|
+
* │ loadAsyncStorage │ throws (web/Node have │ static `import '@react- │
|
|
47
|
+
* │ │ their own storage) │ native-async-storage/...' │
|
|
48
|
+
* ├──────────────────┼───────────────────────┼───────────────────────────────┤
|
|
49
|
+
* │ getRandomBytesRN │ throws (RN-only) │ direct call into expo-crypto │
|
|
50
|
+
* └──────────────────┴───────────────────────┴───────────────────────────────┘
|
|
51
|
+
*
|
|
52
|
+
* Crucially, the default variant references ONLY Node's `'crypto'`. It never
|
|
53
|
+
* mentions `expo-*` or `@react-native-async-storage/*` — so Vite, webpack,
|
|
54
|
+
* esbuild, Rollup, and Node itself can bundle / require it without ever
|
|
55
|
+
* attempting to resolve those RN-only packages.
|
|
56
|
+
*
|
|
57
|
+
* The React Native variant references ONLY the RN packages. It never
|
|
58
|
+
* mentions `'crypto'` — so Metro and Hermes have nothing to choke on.
|
|
59
|
+
*
|
|
60
|
+
* # Why not a single file with dynamic import?
|
|
61
|
+
*
|
|
62
|
+
* A previous iteration used a "bundler-opaque" `new Function('s', 'return
|
|
63
|
+
* import(s)')` trick so a single file could service every platform. It
|
|
64
|
+
* bundled cleanly on Metro but Hermes refused to PARSE the resulting
|
|
65
|
+
* `import()` expression inside a Function-constructor body
|
|
66
|
+
* (`SyntaxError: Invalid expression encountered` at the `(` of `import(`).
|
|
67
|
+
* The platform-extension split is the only approach that lets each runtime
|
|
68
|
+
* see a file containing only specifiers it can understand — no tricks, no
|
|
69
|
+
* runtime parsing risks.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
import { isReactNative } from './platform';
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Node `crypto` — Node built-in
|
|
76
|
+
//
|
|
77
|
+
// `await import('crypto')` here is a real, static-from-tsc's-perspective
|
|
78
|
+
// dynamic import. Node ESM, Vite, webpack, and esbuild all resolve it fine.
|
|
79
|
+
// Metro never sees this file because the `.react-native.js` sibling shadows
|
|
80
|
+
// it, so Metro never tries to resolve `'crypto'`.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
let cachedNodeCrypto: typeof import('crypto') | null = null;
|
|
84
|
+
|
|
85
|
+
export async function loadNodeCrypto(): Promise<typeof import('crypto')> {
|
|
86
|
+
if (cachedNodeCrypto) {
|
|
87
|
+
return cachedNodeCrypto;
|
|
88
|
+
}
|
|
89
|
+
cachedNodeCrypto = await import('node:crypto');
|
|
90
|
+
return cachedNodeCrypto;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// RN-only modules — never called from this variant.
|
|
95
|
+
//
|
|
96
|
+
// These throw a clear error if anything ever reaches them outside RN. In
|
|
97
|
+
// practice every caller gates with `isReactNative()` before calling, so
|
|
98
|
+
// these are belt-and-braces.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function notReactNativeError(module: string): Error {
|
|
102
|
+
return new Error(
|
|
103
|
+
`[oxy.platformCrypto] Tried to load '${module}' outside React Native. This module is only available in a React Native runtime; bundling routed this consumer to the default (Node/web) variant. This indicates a missing platform gate (\`isReactNative()\`) in the calling code.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function loadExpoCrypto(): Promise<typeof import('expo-crypto')> {
|
|
108
|
+
if (isReactNative()) {
|
|
109
|
+
// Should be unreachable: when running on RN, Metro / the `react-native`
|
|
110
|
+
// exports condition serves the sibling variant. If we got here, the
|
|
111
|
+
// package-exports map is misconfigured for this host. Throw with a
|
|
112
|
+
// helpful diagnostic rather than fall back to a broken dynamic import.
|
|
113
|
+
throw new Error(
|
|
114
|
+
'[oxy.platformCrypto] React Native runtime resolved the default ' +
|
|
115
|
+
'(non-RN) variant of @oxyhq/core/utils/platformCrypto. Check the ' +
|
|
116
|
+
"consumer's bundler resolution — Metro should pick the sibling " +
|
|
117
|
+
'.react-native.js file via package exports.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
throw notReactNativeError('expo-crypto');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loadSecureStore(): Promise<typeof import('expo-secure-store')> {
|
|
124
|
+
throw notReactNativeError('expo-secure-store');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function loadAsyncStorage(): Promise<{
|
|
128
|
+
default: {
|
|
129
|
+
getItem: (key: string) => Promise<string | null>;
|
|
130
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
131
|
+
removeItem: (key: string) => Promise<void>;
|
|
132
|
+
};
|
|
133
|
+
}> {
|
|
134
|
+
throw notReactNativeError('@react-native-async-storage/async-storage');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Synchronous random-bytes via `expo-crypto.getRandomBytes`. Only available
|
|
139
|
+
* in the React Native variant. The default variant throws because Node and
|
|
140
|
+
* browsers have their own native CSPRNGs (`crypto.randomBytes` and
|
|
141
|
+
* `crypto.getRandomValues` respectively) — callers should use those.
|
|
142
|
+
*/
|
|
143
|
+
export function getRandomBytesRN(_byteCount: number): Uint8Array {
|
|
144
|
+
throw notReactNativeError('expo-crypto.getRandomBytes (sync)');
|
|
145
|
+
}
|