@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
|
@@ -7,6 +7,12 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
7
7
|
/** @internal */ this._serviceTokenExp = 0;
|
|
8
8
|
/** @internal */ this._serviceApiKey = null;
|
|
9
9
|
/** @internal */ this._serviceApiSecret = null;
|
|
10
|
+
/**
|
|
11
|
+
* In-flight promise for service token fetch. Used to deduplicate concurrent
|
|
12
|
+
* calls to getServiceToken() — pattern mirrors AuthManager.refreshToken().
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
this._serviceTokenPromise = null;
|
|
10
16
|
}
|
|
11
17
|
/**
|
|
12
18
|
* Configure service credentials for internal service-to-service communication.
|
|
@@ -27,6 +33,9 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
27
33
|
* Get a service token for internal service-to-service communication.
|
|
28
34
|
* Tokens are short-lived (1h) and automatically cached/refreshed.
|
|
29
35
|
*
|
|
36
|
+
* Concurrent callers share a single in-flight request to avoid hammering
|
|
37
|
+
* `/auth/service-token` when the cache is empty or expired.
|
|
38
|
+
*
|
|
30
39
|
* @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
|
|
31
40
|
* @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
|
|
32
41
|
*/
|
|
@@ -40,6 +49,24 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
40
49
|
if (this._serviceToken && this._serviceTokenExp > Date.now() + 60000) {
|
|
41
50
|
return this._serviceToken;
|
|
42
51
|
}
|
|
52
|
+
// If a fetch is already in-flight, share the same promise
|
|
53
|
+
if (this._serviceTokenPromise) {
|
|
54
|
+
return this._serviceTokenPromise;
|
|
55
|
+
}
|
|
56
|
+
this._serviceTokenPromise = this._doFetchServiceToken(key, secret);
|
|
57
|
+
try {
|
|
58
|
+
return await this._serviceTokenPromise;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
this._serviceTokenPromise = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Perform the actual /auth/service-token request and cache the result.
|
|
66
|
+
* Separated so getServiceToken() can deduplicate concurrent calls.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
async _doFetchServiceToken(key, secret) {
|
|
43
70
|
const response = await this.makeRequest('POST', '/auth/service-token', { apiKey: key, apiSecret: secret }, { cache: false, retry: false });
|
|
44
71
|
this._serviceToken = response.token;
|
|
45
72
|
this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact Discovery Mixin
|
|
3
|
+
*
|
|
4
|
+
* Privacy-preserving discovery of which address-book contacts are on Oxy.
|
|
5
|
+
*
|
|
6
|
+
* The client hashes emails and phones locally before calling the API.
|
|
7
|
+
* The server responds with only Oxy user IDs and the hashes that matched,
|
|
8
|
+
* so the consumer can map each match back to the local contact that
|
|
9
|
+
* produced it.
|
|
10
|
+
*
|
|
11
|
+
* Hashing rules (must match the server `utils/contactHash.ts` exactly):
|
|
12
|
+
* - SHA-256, hex-encoded, lowercase
|
|
13
|
+
* - Email: `value.trim().toLowerCase()` then digest
|
|
14
|
+
* - Phone: trim → keep a single leading "+" → strip non-digits → prepend "+"
|
|
15
|
+
* if missing → digest
|
|
16
|
+
*
|
|
17
|
+
* Mobile clients can compute these digests with `expo-crypto`'s
|
|
18
|
+
* `digestStringAsync(SHA256, value, { encoding: HEX })`. Web clients should
|
|
19
|
+
* use `SubtleCrypto.digest('SHA-256', ...)`.
|
|
20
|
+
*/
|
|
21
|
+
export function OxyServicesContactsMixin(Base) {
|
|
22
|
+
return class extends Base {
|
|
23
|
+
constructor(...args) {
|
|
24
|
+
super(...args);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Discover which of the caller's contacts are on Oxy.
|
|
28
|
+
*
|
|
29
|
+
* @param hashedEmails - SHA-256 hex digests of normalized emails.
|
|
30
|
+
* @param hashedPhones - SHA-256 hex digests of normalized phone numbers.
|
|
31
|
+
* @returns Matches mapping each hashed identifier to the Oxy user ID it
|
|
32
|
+
* resolved to. Empty arrays are valid for either parameter, but at
|
|
33
|
+
* least one must be non-empty.
|
|
34
|
+
*
|
|
35
|
+
* The server enforces a 200-hash cap per channel per request — callers
|
|
36
|
+
* should batch larger address books client-side.
|
|
37
|
+
*/
|
|
38
|
+
async discoverContacts(hashedEmails, hashedPhones) {
|
|
39
|
+
try {
|
|
40
|
+
return await this.makeRequest('POST', '/contacts/discover', { hashedEmails, hashedPhones }, { cache: false });
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw this.handleError(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -291,16 +291,5 @@ export function OxyServicesFeaturesMixin(Base) {
|
|
|
291
291
|
throw this.handleError(error);
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
-
// ==================
|
|
295
|
-
// ACCOUNT
|
|
296
|
-
// ==================
|
|
297
|
-
/**
|
|
298
|
-
* Delete user account (requires password confirmation)
|
|
299
|
-
*/
|
|
300
|
-
async deleteAccount(password) {
|
|
301
|
-
return this.withAuthRetry(async () => {
|
|
302
|
-
await this.makeRequest('DELETE', '/account', { password }, { cache: false });
|
|
303
|
-
}, 'deleteAccount');
|
|
304
|
-
}
|
|
305
294
|
};
|
|
306
295
|
}
|
|
@@ -311,10 +311,11 @@ export function OxyServicesFedCMMixin(Base) {
|
|
|
311
311
|
{
|
|
312
312
|
configURL: options.configURL,
|
|
313
313
|
clientId: options.clientId,
|
|
314
|
-
//
|
|
315
|
-
|
|
314
|
+
// Older browsers read `nonce` at the top level; Chrome 145+
|
|
315
|
+
// expects it inside `params`. Send both for full coverage.
|
|
316
|
+
nonce: options.nonce,
|
|
316
317
|
params: {
|
|
317
|
-
nonce: options.nonce,
|
|
318
|
+
nonce: options.nonce,
|
|
318
319
|
},
|
|
319
320
|
...(options.loginHint && { loginHint: options.loginHint }),
|
|
320
321
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Language Methods Mixin
|
|
3
3
|
*/
|
|
4
4
|
import { normalizeLanguageCode, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils.js';
|
|
5
|
+
import { loadAsyncStorage } from '../utils/platformCrypto.js';
|
|
5
6
|
import { isDev } from '../shared/utils/debugUtils.js';
|
|
6
7
|
export function OxyServicesLanguageMixin(Base) {
|
|
7
8
|
return class extends Base {
|
|
@@ -15,9 +16,10 @@ export function OxyServicesLanguageMixin(Base) {
|
|
|
15
16
|
const isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
16
17
|
if (isReactNative) {
|
|
17
18
|
try {
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// `loadAsyncStorage` is per-platform: the RN variant statically imports
|
|
20
|
+
// @react-native-async-storage/async-storage, the default variant throws
|
|
21
|
+
// (never called outside RN because of the `isReactNative` gate above).
|
|
22
|
+
const asyncStorageModule = await loadAsyncStorage();
|
|
21
23
|
const storage = asyncStorageModule.default;
|
|
22
24
|
return {
|
|
23
25
|
getItem: storage.getItem.bind(storage),
|
|
@@ -149,12 +149,16 @@ export function OxyServicesRedirectAuthMixin(Base) {
|
|
|
149
149
|
// Store tokens
|
|
150
150
|
this.storeTokens(accessToken, sessionId);
|
|
151
151
|
this.httpService.setTokens(accessToken);
|
|
152
|
-
// Build session response (minimal
|
|
152
|
+
// Build session response (minimal — full user data is fetched separately
|
|
153
|
+
// by the caller via getCurrentUser() once tokens are stored).
|
|
153
154
|
const session = {
|
|
154
155
|
sessionId,
|
|
155
156
|
deviceId: '', // Not available in redirect flow
|
|
156
157
|
expiresAt: expiresAt || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
157
|
-
user
|
|
158
|
+
// Placeholder user — caller MUST fetch real user data via getCurrentUser()
|
|
159
|
+
// before exposing this session to the application. The empty id signals
|
|
160
|
+
// that the user payload has not yet been populated.
|
|
161
|
+
user: { id: '', username: '' },
|
|
158
162
|
};
|
|
159
163
|
// Clean up URL (remove auth parameters)
|
|
160
164
|
this.cleanAuthCallbackUrl(url);
|
|
@@ -20,8 +20,19 @@ export function OxyServicesSecurityMixin(Base) {
|
|
|
20
20
|
params.offset = offset;
|
|
21
21
|
if (eventType)
|
|
22
22
|
params.eventType = eventType;
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// The API responds with the standard paginated envelope:
|
|
24
|
+
// { data: SecurityActivity[], pagination: { total, limit, offset, hasMore } }
|
|
25
|
+
// SecurityActivityResponse is the flattened shape consumers expect.
|
|
26
|
+
const raw = await this.makeRequest('GET', '/security/activity', params, { cache: false });
|
|
27
|
+
const requestedLimit = typeof params.limit === 'number' ? params.limit : 0;
|
|
28
|
+
const requestedOffset = typeof params.offset === 'number' ? params.offset : 0;
|
|
29
|
+
return {
|
|
30
|
+
data: raw.data ?? [],
|
|
31
|
+
total: raw.pagination?.total ?? raw.data?.length ?? 0,
|
|
32
|
+
limit: raw.pagination?.limit ?? requestedLimit,
|
|
33
|
+
offset: raw.pagination?.offset ?? requestedOffset,
|
|
34
|
+
hasMore: raw.pagination?.hasMore ?? false,
|
|
35
|
+
};
|
|
25
36
|
}
|
|
26
37
|
catch (error) {
|
|
27
38
|
throw this.handleError(error);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { buildSearchParams, buildPaginationParams } from '../utils/apiUtils.js';
|
|
2
|
+
import { KeyManager } from '../crypto/keyManager.js';
|
|
3
|
+
import { SignatureService } from '../crypto/signatureService.js';
|
|
2
4
|
export function OxyServicesUserMixin(Base) {
|
|
3
5
|
return class extends Base {
|
|
4
6
|
constructor(...args) {
|
|
@@ -18,6 +20,17 @@ export function OxyServicesUserMixin(Base) {
|
|
|
18
20
|
throw this.handleError(error);
|
|
19
21
|
}
|
|
20
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Lightweight username lookup for login flows.
|
|
25
|
+
* Returns minimal public info: exists, color, avatar, displayName.
|
|
26
|
+
* Faster than getProfileByUsername — no stats, no formatting.
|
|
27
|
+
*/
|
|
28
|
+
async lookupUsername(username) {
|
|
29
|
+
return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
|
|
30
|
+
cache: true,
|
|
31
|
+
cacheTTL: 60 * 1000, // 1 minute cache
|
|
32
|
+
});
|
|
33
|
+
}
|
|
21
34
|
/**
|
|
22
35
|
* Search user profiles
|
|
23
36
|
*/
|
|
@@ -30,37 +43,22 @@ export function OxyServicesUserMixin(Base) {
|
|
|
30
43
|
cache: true,
|
|
31
44
|
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
32
45
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (isSearchProfilesResponse(response)) {
|
|
38
|
-
const typedResponse = response;
|
|
39
|
-
const paginationInfo = typedResponse.pagination ?? {
|
|
40
|
-
total: typedResponse.data.length,
|
|
41
|
-
limit: pagination?.limit ?? typedResponse.data.length,
|
|
42
|
-
offset: pagination?.offset ?? 0,
|
|
43
|
-
hasMore: typedResponse.data.length === (pagination?.limit ?? typedResponse.data.length) &&
|
|
44
|
-
(pagination?.limit ?? typedResponse.data.length) > 0,
|
|
45
|
-
};
|
|
46
|
-
return {
|
|
47
|
-
data: typedResponse.data,
|
|
48
|
-
pagination: paginationInfo,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
// Legacy API shape: returns raw User[]
|
|
52
|
-
if (Array.isArray(response)) {
|
|
53
|
-
const fallbackLimit = pagination?.limit ?? response.length;
|
|
54
|
-
const fallbackPagination = {
|
|
55
|
-
total: response.length,
|
|
56
|
-
limit: fallbackLimit,
|
|
57
|
-
offset: pagination?.offset ?? 0,
|
|
58
|
-
hasMore: fallbackLimit > 0 && response.length === fallbackLimit,
|
|
59
|
-
};
|
|
60
|
-
return { data: response, pagination: fallbackPagination };
|
|
46
|
+
if (typeof response !== 'object' ||
|
|
47
|
+
response === null ||
|
|
48
|
+
!Array.isArray(response.data)) {
|
|
49
|
+
throw new Error('Unexpected search response format');
|
|
61
50
|
}
|
|
62
|
-
|
|
63
|
-
|
|
51
|
+
const paginationInfo = response.pagination ?? {
|
|
52
|
+
total: response.data.length,
|
|
53
|
+
limit: pagination?.limit ?? response.data.length,
|
|
54
|
+
offset: pagination?.offset ?? 0,
|
|
55
|
+
hasMore: response.data.length === (pagination?.limit ?? response.data.length) &&
|
|
56
|
+
(pagination?.limit ?? response.data.length) > 0,
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
data: response.data,
|
|
60
|
+
pagination: paginationInfo,
|
|
61
|
+
};
|
|
64
62
|
}
|
|
65
63
|
catch (error) {
|
|
66
64
|
throw this.handleError(error);
|
|
@@ -142,12 +140,31 @@ export function OxyServicesUserMixin(Base) {
|
|
|
142
140
|
}, 'getCurrentUser');
|
|
143
141
|
}
|
|
144
142
|
/**
|
|
145
|
-
* Update user profile
|
|
146
|
-
*
|
|
143
|
+
* Update user profile.
|
|
144
|
+
*
|
|
145
|
+
* Invalidates the SDK-side response cache for every endpoint that
|
|
146
|
+
* returns the current user (`GET /users/me`, `GET /session/user/*`,
|
|
147
|
+
* `GET /users/<id>`, `GET /profiles/username/*`) so the next read
|
|
148
|
+
* doesn't return a stale snapshot. Without this, a follow-up
|
|
149
|
+
* `getUserBySession` call inside the 2-minute cache window can return
|
|
150
|
+
* the pre-update user — most visibly during onboarding, where it
|
|
151
|
+
* causes the username step to flicker back as if nothing was saved.
|
|
152
|
+
*
|
|
153
|
+
* TanStack Query handles offline queuing automatically.
|
|
147
154
|
*/
|
|
148
155
|
async updateProfile(updates) {
|
|
149
156
|
try {
|
|
150
|
-
|
|
157
|
+
const result = await this.makeRequest('PUT', '/users/me', updates, { cache: false });
|
|
158
|
+
// Bust every cached representation of the current user. We use a
|
|
159
|
+
// prefix sweep rather than an enumeration because the SDK never
|
|
160
|
+
// tracks the set of active session IDs centrally.
|
|
161
|
+
this.clearCacheByPrefix('GET:/session/user/');
|
|
162
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
163
|
+
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
164
|
+
if (result?.id) {
|
|
165
|
+
this.clearCacheEntry(`GET:/users/${result.id}`);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
151
168
|
}
|
|
152
169
|
catch (error) {
|
|
153
170
|
const errorAny = error;
|
|
@@ -231,14 +248,29 @@ export function OxyServicesUserMixin(Base) {
|
|
|
231
248
|
}
|
|
232
249
|
}
|
|
233
250
|
/**
|
|
234
|
-
* Delete account permanently
|
|
235
|
-
*
|
|
236
|
-
*
|
|
251
|
+
* Delete account permanently.
|
|
252
|
+
*
|
|
253
|
+
* Signs `delete:{publicKey}:{timestamp}` with the locally-stored identity
|
|
254
|
+
* private key and submits the signature alongside the confirmation text
|
|
255
|
+
* (must equal the user's username). The signature is the cryptographic
|
|
256
|
+
* proof of ownership — only the device holding the private key can issue
|
|
257
|
+
* a valid signature, so no password is required.
|
|
258
|
+
*
|
|
259
|
+
* @param confirmText - Must equal the user's username (verified server-side)
|
|
260
|
+
* @throws If no identity is stored on this device, or signing fails
|
|
237
261
|
*/
|
|
238
|
-
async deleteAccount(
|
|
262
|
+
async deleteAccount(confirmText) {
|
|
239
263
|
try {
|
|
264
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
265
|
+
if (!publicKey) {
|
|
266
|
+
throw new Error('No identity found on this device. Account deletion requires the device that holds your identity key.');
|
|
267
|
+
}
|
|
268
|
+
const timestamp = Date.now();
|
|
269
|
+
const message = `delete:${publicKey}:${timestamp}`;
|
|
270
|
+
const signature = await SignatureService.sign(message);
|
|
240
271
|
return await this.makeRequest('DELETE', '/users/me', {
|
|
241
|
-
|
|
272
|
+
signature,
|
|
273
|
+
timestamp,
|
|
242
274
|
confirmText,
|
|
243
275
|
}, { cache: false });
|
|
244
276
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and Express.js authentication middleware
|
|
6
6
|
*/
|
|
7
7
|
import { jwtDecode } from 'jwt-decode';
|
|
8
|
+
import { loadNodeCrypto } from '../utils/platformCrypto.js';
|
|
8
9
|
import { CACHE_TIMES } from './mixinHelpers.js';
|
|
9
10
|
export function OxyServicesUtilityMixin(Base) {
|
|
10
11
|
return class extends Base {
|
|
@@ -218,9 +219,15 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
218
219
|
return onError(error);
|
|
219
220
|
return res.status(403).json(error);
|
|
220
221
|
}
|
|
221
|
-
// Verify JWT signature (not just decode)
|
|
222
|
+
// Verify JWT signature (not just decode).
|
|
223
|
+
// This middleware only runs on a Node Express server, but the file
|
|
224
|
+
// is bundled by Metro/Vite for RN/web consumers. `loadNodeCrypto`
|
|
225
|
+
// is per-platform: the RN variant throws (and is never called
|
|
226
|
+
// because service-token middleware is only mounted by Node hosts),
|
|
227
|
+
// so Metro never bundles a reference to Node's built-in.
|
|
222
228
|
try {
|
|
223
|
-
const
|
|
229
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
230
|
+
const { createHmac, timingSafeEqual } = nodeCrypto;
|
|
224
231
|
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
|
225
232
|
if (!headerB64 || !payloadB64 || !signatureB64) {
|
|
226
233
|
throw new Error('Invalid token structure');
|
|
@@ -234,7 +241,6 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
234
241
|
// Timing-safe comparison
|
|
235
242
|
const sigBuf = Buffer.from(signatureB64);
|
|
236
243
|
const expectedBuf = Buffer.from(expectedSig);
|
|
237
|
-
const { timingSafeEqual } = await import('crypto');
|
|
238
244
|
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
239
245
|
throw new Error('Invalid signature');
|
|
240
246
|
}
|
|
@@ -259,8 +265,8 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
259
265
|
return onError(error);
|
|
260
266
|
return res.status(401).json(error);
|
|
261
267
|
}
|
|
262
|
-
// Check expiration
|
|
263
|
-
if (decoded.exp && decoded.exp
|
|
268
|
+
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
269
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
264
270
|
if (optional) {
|
|
265
271
|
req.userId = null;
|
|
266
272
|
req.user = null;
|
|
@@ -315,7 +321,8 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
315
321
|
return res.status(401).json(error);
|
|
316
322
|
}
|
|
317
323
|
// Check token expiration locally first (fast path)
|
|
318
|
-
|
|
324
|
+
// Reject tokens at exact expiry second (use <=)
|
|
325
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
319
326
|
if (optional) {
|
|
320
327
|
req.userId = null;
|
|
321
328
|
req.user = null;
|
|
@@ -482,8 +489,8 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
482
489
|
if (!userId) {
|
|
483
490
|
return next(new Error('Invalid token payload'));
|
|
484
491
|
}
|
|
485
|
-
// Check expiration
|
|
486
|
-
if (decoded.exp && decoded.exp
|
|
492
|
+
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
493
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
487
494
|
return next(new Error('Token expired'));
|
|
488
495
|
}
|
|
489
496
|
// Validate session if available
|
|
@@ -500,12 +507,14 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
500
507
|
return next(new Error('Session validation failed'));
|
|
501
508
|
}
|
|
502
509
|
}
|
|
503
|
-
// Attach user data to socket
|
|
510
|
+
// Attach user data to socket. We expose BOTH `socket.data.userId`
|
|
511
|
+
// (the official Socket.IO data slot) and `socket.user` because
|
|
512
|
+
// every consumer in this ecosystem (Mention, Allo, api/server.ts)
|
|
513
|
+
// reads from `socket.user.id`.
|
|
504
514
|
socket.data = socket.data || {};
|
|
505
515
|
socket.data.userId = userId;
|
|
506
516
|
socket.data.sessionId = decoded.sessionId || null;
|
|
507
517
|
socket.data.token = token;
|
|
508
|
-
// Also set on socket.user for backward compatibility
|
|
509
518
|
socket.user = { id: userId, userId, sessionId: decoded.sessionId };
|
|
510
519
|
if (debug) {
|
|
511
520
|
console.log(`[oxy.authSocket] OK user=${userId}`);
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility.js';
|
|
|
24
24
|
import { OxyServicesFeaturesMixin } from './OxyServices.features.js';
|
|
25
25
|
import { OxyServicesTopicsMixin } from './OxyServices.topics.js';
|
|
26
26
|
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts.js';
|
|
27
|
+
import { OxyServicesContactsMixin } from './OxyServices.contacts.js';
|
|
27
28
|
/**
|
|
28
29
|
* Mixin pipeline - applied in order from first to last.
|
|
29
30
|
*
|
|
@@ -62,6 +63,7 @@ const MIXIN_PIPELINE = [
|
|
|
62
63
|
OxyServicesFeaturesMixin,
|
|
63
64
|
OxyServicesTopicsMixin,
|
|
64
65
|
OxyServicesManagedAccountsMixin,
|
|
66
|
+
OxyServicesContactsMixin,
|
|
65
67
|
// Utility (last, can use all above)
|
|
66
68
|
OxyServicesUtilityMixin,
|
|
67
69
|
];
|
|
@@ -69,12 +71,18 @@ const MIXIN_PIPELINE = [
|
|
|
69
71
|
* Composes all OxyServices mixins using a pipeline pattern.
|
|
70
72
|
*
|
|
71
73
|
* This is equivalent to the nested calls but more readable and maintainable.
|
|
72
|
-
* Adding a new mixin:
|
|
74
|
+
* Adding a new mixin: add it to MIXIN_PIPELINE at the appropriate position
|
|
75
|
+
* AND extend `AllMixinInstances` so its methods are visible to consumers.
|
|
73
76
|
*
|
|
74
|
-
*
|
|
77
|
+
* The cast through `unknown` carries the runtime augmentation chain into the
|
|
78
|
+
* static type system. `Array.reduce` cannot track each mixin's generic
|
|
79
|
+
* refinement, so we assert the final shape exposed by all mixins together.
|
|
80
|
+
*
|
|
81
|
+
* @returns The fully composed OxyServices constructor with all mixins applied
|
|
75
82
|
*/
|
|
76
83
|
export function composeOxyServices() {
|
|
77
|
-
|
|
84
|
+
const composed = MIXIN_PIPELINE.reduce((Base, mixin) => mixin(Base), OxyServicesBase);
|
|
85
|
+
return composed;
|
|
78
86
|
}
|
|
79
87
|
// Export the pipeline for testing/debugging
|
|
80
88
|
export { MIXIN_PIPELINE };
|
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
* Shared account types and pure helper functions.
|
|
3
3
|
* Used by both @oxyhq/services (React Native) and @oxyhq/auth (Web) account stores.
|
|
4
4
|
*/
|
|
5
|
+
import { translate } from '../i18n/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Truncate a long public key for display, e.g. `0x12345678…`.
|
|
8
|
+
* Falls back to the raw key if it's too short to truncate.
|
|
9
|
+
*/
|
|
10
|
+
export const formatPublicKeyHandle = (publicKey) => {
|
|
11
|
+
const cleaned = publicKey.startsWith('0x') ? publicKey.slice(2) : publicKey;
|
|
12
|
+
if (cleaned.length <= 8)
|
|
13
|
+
return `0x${cleaned}`;
|
|
14
|
+
return `0x${cleaned.slice(0, 8)}…`;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a friendly display name for a user.
|
|
18
|
+
*
|
|
19
|
+
* Order of preference:
|
|
20
|
+
* 1. `name.full`, or composed `name.first name.last`
|
|
21
|
+
* 2. `name` (when stored as a plain string)
|
|
22
|
+
* 3. `username`
|
|
23
|
+
* 4. `Account 0x12345678…` (derived from publicKey, when present)
|
|
24
|
+
* 5. Translated fallback (e.g. "Unnamed")
|
|
25
|
+
*
|
|
26
|
+
* The translation key `common.unnamed` is used for the final fallback. If the
|
|
27
|
+
* caller does not pass a locale, the default English translation is used.
|
|
28
|
+
*/
|
|
29
|
+
export const getAccountDisplayName = (user, locale) => {
|
|
30
|
+
if (!user)
|
|
31
|
+
return translate(locale, 'common.unnamed');
|
|
32
|
+
const { name, username, publicKey } = user;
|
|
33
|
+
if (name && typeof name === 'object') {
|
|
34
|
+
if (typeof name.full === 'string' && name.full.trim())
|
|
35
|
+
return name.full.trim();
|
|
36
|
+
const first = typeof name.first === 'string' ? name.first.trim() : '';
|
|
37
|
+
const last = typeof name.last === 'string' ? name.last.trim() : '';
|
|
38
|
+
const composed = [first, last].filter(Boolean).join(' ').trim();
|
|
39
|
+
if (composed)
|
|
40
|
+
return composed;
|
|
41
|
+
}
|
|
42
|
+
else if (typeof name === 'string' && name.trim()) {
|
|
43
|
+
return name.trim();
|
|
44
|
+
}
|
|
45
|
+
if (typeof username === 'string' && username.trim())
|
|
46
|
+
return username.trim();
|
|
47
|
+
if (typeof publicKey === 'string' && publicKey.length > 0) {
|
|
48
|
+
return translate(locale, 'common.accountFallback', {
|
|
49
|
+
handle: formatPublicKeyHandle(publicKey),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return translate(locale, 'common.unnamed');
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a `@handle` style identifier for a user.
|
|
56
|
+
*
|
|
57
|
+
* Returns the bare username when present (without the `@`), otherwise a
|
|
58
|
+
* truncated public-key handle (`0x12345678…`), or `undefined` when neither is
|
|
59
|
+
* available — callers can decide whether to hide the line entirely.
|
|
60
|
+
*/
|
|
61
|
+
export const getAccountFallbackHandle = (user) => {
|
|
62
|
+
if (!user)
|
|
63
|
+
return undefined;
|
|
64
|
+
if (typeof user.username === 'string' && user.username.trim())
|
|
65
|
+
return user.username.trim();
|
|
66
|
+
if (typeof user.publicKey === 'string' && user.publicKey.length > 0) {
|
|
67
|
+
return formatPublicKeyHandle(user.publicKey);
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
5
71
|
/**
|
|
6
72
|
* Build an ordered array of QuickAccounts from a map and order list.
|
|
7
73
|
*/
|
|
@@ -23,7 +89,7 @@ export const buildAccountsArray = (accounts, order) => {
|
|
|
23
89
|
* @param getFileDownloadUrl - Function to generate avatar download URL from file ID
|
|
24
90
|
*/
|
|
25
91
|
export const createQuickAccount = (sessionId, userData, existingAccount, getFileDownloadUrl) => {
|
|
26
|
-
const displayName = userData
|
|
92
|
+
const displayName = getAccountDisplayName(userData);
|
|
27
93
|
const userId = userData.id || (typeof userData._id === 'string' ? userData._id : userData._id?.toString());
|
|
28
94
|
// Preserve existing avatarUrl if avatar hasn't changed (prevents image reload)
|
|
29
95
|
let avatarUrl;
|
|
@@ -30,18 +30,47 @@ export async function parallelWithErrorHandling(operations, errorHandler) {
|
|
|
30
30
|
const results = await Promise.allSettled(operations.map((op, index) => withErrorHandling(op, error => errorHandler?.(error, index))));
|
|
31
31
|
return results.map(result => result.status === 'fulfilled' ? result.value : null);
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Extract an HTTP status code from an error value, tolerating both the
|
|
35
|
+
* axios-style nested shape (`error.response.status`) and the flat shape
|
|
36
|
+
* produced by {@link handleHttpError} / fetch-based clients (`error.status`).
|
|
37
|
+
*
|
|
38
|
+
* Centralising this lookup prevents retry predicates from silently falling
|
|
39
|
+
* through when one of the two shapes is missing, which previously caused
|
|
40
|
+
* @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
|
|
41
|
+
* multi-second stalls for every missing-resource lookup.
|
|
42
|
+
*/
|
|
43
|
+
function extractHttpStatus(error) {
|
|
44
|
+
if (!error || typeof error !== 'object')
|
|
45
|
+
return undefined;
|
|
46
|
+
const candidate = error;
|
|
47
|
+
const flat = candidate.status;
|
|
48
|
+
if (typeof flat === 'number' && Number.isFinite(flat))
|
|
49
|
+
return flat;
|
|
50
|
+
const nested = candidate.response?.status;
|
|
51
|
+
if (typeof nested === 'number' && Number.isFinite(nested))
|
|
52
|
+
return nested;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
33
55
|
/**
|
|
34
56
|
* Retry an async operation with exponential backoff
|
|
35
57
|
*
|
|
36
|
-
* By default, does not retry on 4xx errors (client errors).
|
|
37
|
-
*
|
|
58
|
+
* By default, does not retry on 4xx errors (client errors). The default
|
|
59
|
+
* predicate accepts both the axios-style `error.response.status` and the
|
|
60
|
+
* flat `error.status` shape produced by {@link handleHttpError}, so callers
|
|
61
|
+
* never accidentally retry a deterministic client failure.
|
|
62
|
+
*
|
|
63
|
+
* Use the `shouldRetry` callback to customize retry behavior.
|
|
38
64
|
*/
|
|
39
65
|
export async function retryAsync(operation, maxRetries = 3, baseDelay = 1000, shouldRetry) {
|
|
40
66
|
let lastError;
|
|
41
|
-
// Default shouldRetry: don't retry on 4xx errors
|
|
67
|
+
// Default shouldRetry: don't retry on 4xx errors (client errors).
|
|
68
|
+
// Checks BOTH `error.status` (flat shape from handleHttpError / fetch
|
|
69
|
+
// clients) AND `error.response.status` (axios-style shape) so neither
|
|
70
|
+
// representation can leak a client error into the retry loop.
|
|
42
71
|
const defaultShouldRetry = (error) => {
|
|
43
|
-
|
|
44
|
-
if (
|
|
72
|
+
const status = extractHttpStatus(error);
|
|
73
|
+
if (status !== undefined && status >= 400 && status < 500) {
|
|
45
74
|
return false;
|
|
46
75
|
}
|
|
47
76
|
return true;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadAsyncStorage } from './platformCrypto.js';
|
|
1
2
|
/**
|
|
2
3
|
* Client-side device management utility
|
|
3
4
|
* Handles persistent device identification across app sessions
|
|
@@ -15,9 +16,10 @@ export class DeviceManager {
|
|
|
15
16
|
static async getStorage() {
|
|
16
17
|
if (this.isReactNative()) {
|
|
17
18
|
try {
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// `loadAsyncStorage` is per-platform: the RN variant statically imports
|
|
20
|
+
// @react-native-async-storage/async-storage, the default variant throws
|
|
21
|
+
// (never called outside RN because of the `isReactNative()` gate above).
|
|
22
|
+
const asyncStorageModule = await loadAsyncStorage();
|
|
21
23
|
const storage = asyncStorageModule.default;
|
|
22
24
|
return {
|
|
23
25
|
getItem: storage.getItem.bind(storage),
|