@oxyhq/core 1.11.12 → 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.
Files changed (125) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +214 -33
  4. package/dist/cjs/OxyServices.base.js +9 -0
  5. package/dist/cjs/OxyServices.js +8 -3
  6. package/dist/cjs/crypto/index.js +3 -1
  7. package/dist/cjs/crypto/keyManager.js +476 -172
  8. package/dist/cjs/crypto/polyfill.js +14 -65
  9. package/dist/cjs/crypto/recoveryPhrase.js +30 -11
  10. package/dist/cjs/crypto/signatureService.js +25 -60
  11. package/dist/cjs/i18n/locales/en-US.json +46 -1
  12. package/dist/cjs/i18n/locales/es-ES.json +46 -1
  13. package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
  14. package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
  15. package/dist/cjs/index.js +7 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +27 -0
  18. package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
  19. package/dist/cjs/mixins/OxyServices.features.js +0 -11
  20. package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
  21. package/dist/cjs/mixins/OxyServices.language.js +5 -36
  22. package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
  23. package/dist/cjs/mixins/OxyServices.security.js +13 -2
  24. package/dist/cjs/mixins/OxyServices.user.js +59 -38
  25. package/dist/cjs/mixins/OxyServices.utility.js +19 -43
  26. package/dist/cjs/mixins/index.js +11 -3
  27. package/dist/cjs/utils/accountUtils.js +71 -2
  28. package/dist/cjs/utils/deviceManager.js +5 -36
  29. package/dist/cjs/utils/platformCrypto.js +165 -0
  30. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/CrossDomainAuth.js +3 -1
  33. package/dist/esm/HttpService.js +215 -34
  34. package/dist/esm/OxyServices.base.js +9 -0
  35. package/dist/esm/OxyServices.js +8 -3
  36. package/dist/esm/crypto/index.js +1 -1
  37. package/dist/esm/crypto/keyManager.js +473 -138
  38. package/dist/esm/crypto/polyfill.js +14 -32
  39. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  40. package/dist/esm/crypto/signatureService.js +25 -27
  41. package/dist/esm/i18n/locales/en-US.json +46 -1
  42. package/dist/esm/i18n/locales/es-ES.json +46 -1
  43. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  44. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  45. package/dist/esm/index.js +2 -2
  46. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  47. package/dist/esm/mixins/OxyServices.auth.js +27 -0
  48. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  49. package/dist/esm/mixins/OxyServices.features.js +0 -11
  50. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  51. package/dist/esm/mixins/OxyServices.language.js +5 -3
  52. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  53. package/dist/esm/mixins/OxyServices.security.js +13 -2
  54. package/dist/esm/mixins/OxyServices.user.js +59 -38
  55. package/dist/esm/mixins/OxyServices.utility.js +19 -10
  56. package/dist/esm/mixins/index.js +11 -3
  57. package/dist/esm/utils/accountUtils.js +67 -1
  58. package/dist/esm/utils/deviceManager.js +5 -3
  59. package/dist/esm/utils/platformCrypto.js +125 -0
  60. package/dist/esm/utils/platformCrypto.native.js +80 -0
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/HttpService.d.ts +47 -3
  63. package/dist/types/OxyServices.base.d.ts +7 -0
  64. package/dist/types/OxyServices.d.ts +36 -3
  65. package/dist/types/crypto/index.d.ts +1 -1
  66. package/dist/types/crypto/keyManager.d.ts +110 -9
  67. package/dist/types/crypto/polyfill.d.ts +3 -1
  68. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  69. package/dist/types/crypto/signatureService.d.ts +4 -0
  70. package/dist/types/index.d.ts +4 -3
  71. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  72. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  73. package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
  74. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  75. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  78. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.user.d.ts +28 -11
  90. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  91. package/dist/types/mixins/index.d.ts +52 -4
  92. package/dist/types/models/interfaces.d.ts +62 -3
  93. package/dist/types/utils/accountUtils.d.ts +41 -1
  94. package/dist/types/utils/platformCrypto.d.ts +87 -0
  95. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  96. package/package.json +28 -1
  97. package/src/CrossDomainAuth.ts +12 -10
  98. package/src/HttpService.ts +251 -40
  99. package/src/OxyServices.base.ts +10 -0
  100. package/src/OxyServices.ts +9 -4
  101. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  102. package/src/crypto/index.ts +6 -1
  103. package/src/crypto/keyManager.ts +529 -151
  104. package/src/crypto/polyfill.ts +14 -34
  105. package/src/crypto/recoveryPhrase.ts +56 -17
  106. package/src/crypto/signatureService.ts +25 -30
  107. package/src/i18n/locales/en-US.json +46 -1
  108. package/src/i18n/locales/es-ES.json +46 -1
  109. package/src/index.ts +16 -3
  110. package/src/mixins/OxyServices.assets.ts +15 -11
  111. package/src/mixins/OxyServices.auth.ts +28 -0
  112. package/src/mixins/OxyServices.contacts.ts +73 -0
  113. package/src/mixins/OxyServices.features.ts +2 -12
  114. package/src/mixins/OxyServices.fedcm.ts +4 -3
  115. package/src/mixins/OxyServices.language.ts +6 -4
  116. package/src/mixins/OxyServices.redirect.ts +6 -2
  117. package/src/mixins/OxyServices.security.ts +18 -8
  118. package/src/mixins/OxyServices.user.ts +72 -49
  119. package/src/mixins/OxyServices.utility.ts +19 -10
  120. package/src/mixins/index.ts +58 -7
  121. package/src/models/interfaces.ts +65 -3
  122. package/src/utils/accountUtils.ts +82 -2
  123. package/src/utils/deviceManager.ts +7 -4
  124. package/src/utils/platformCrypto.native.ts +101 -0
  125. 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
- // Send nonce at both levels for backward compatibility
315
- nonce: options.nonce, // For older browsers
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, // For Chrome 145+
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
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
19
- const moduleName = '@react-native-async-storage/async-storage';
20
- const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
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 - we'll fetch full user data separately)
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: {}, // Will be fetched separately
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
- const response = await this.makeRequest('GET', '/security/activity', params, { cache: false });
24
- return response;
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) {
@@ -41,37 +43,22 @@ export function OxyServicesUserMixin(Base) {
41
43
  cache: true,
42
44
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
43
45
  });
44
- // New API shape: { data: User[], pagination: {...} }
45
- const isSearchProfilesResponse = (payload) => typeof payload === 'object' &&
46
- payload !== null &&
47
- Array.isArray(payload.data);
48
- if (isSearchProfilesResponse(response)) {
49
- const typedResponse = response;
50
- const paginationInfo = typedResponse.pagination ?? {
51
- total: typedResponse.data.length,
52
- limit: pagination?.limit ?? typedResponse.data.length,
53
- offset: pagination?.offset ?? 0,
54
- hasMore: typedResponse.data.length === (pagination?.limit ?? typedResponse.data.length) &&
55
- (pagination?.limit ?? typedResponse.data.length) > 0,
56
- };
57
- return {
58
- data: typedResponse.data,
59
- pagination: paginationInfo,
60
- };
46
+ if (typeof response !== 'object' ||
47
+ response === null ||
48
+ !Array.isArray(response.data)) {
49
+ throw new Error('Unexpected search response format');
61
50
  }
62
- // Legacy API shape: returns raw User[]
63
- if (Array.isArray(response)) {
64
- const fallbackLimit = pagination?.limit ?? response.length;
65
- const fallbackPagination = {
66
- total: response.length,
67
- limit: fallbackLimit,
68
- offset: pagination?.offset ?? 0,
69
- hasMore: fallbackLimit > 0 && response.length === fallbackLimit,
70
- };
71
- return { data: response, pagination: fallbackPagination };
72
- }
73
- // If response is unexpected, throw an error
74
- throw new Error('Unexpected search response format');
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
+ };
75
62
  }
76
63
  catch (error) {
77
64
  throw this.handleError(error);
@@ -153,12 +140,31 @@ export function OxyServicesUserMixin(Base) {
153
140
  }, 'getCurrentUser');
154
141
  }
155
142
  /**
156
- * Update user profile
157
- * TanStack Query handles offline queuing automatically
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.
158
154
  */
159
155
  async updateProfile(updates) {
160
156
  try {
161
- return await this.makeRequest('PUT', '/users/me', updates, { cache: false });
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;
162
168
  }
163
169
  catch (error) {
164
170
  const errorAny = error;
@@ -242,14 +248,29 @@ export function OxyServicesUserMixin(Base) {
242
248
  }
243
249
  }
244
250
  /**
245
- * Delete account permanently
246
- * @param password - User password for confirmation
247
- * @param confirmText - Confirmation text (usually username)
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
248
261
  */
249
- async deleteAccount(password, confirmText) {
262
+ async deleteAccount(confirmText) {
250
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);
251
271
  return await this.makeRequest('DELETE', '/users/me', {
252
- password,
272
+ signature,
273
+ timestamp,
253
274
  confirmText,
254
275
  }, { cache: false });
255
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 { createHmac } = await import('crypto');
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 < Math.floor(Date.now() / 1000)) {
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
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
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 < Math.floor(Date.now() / 1000)) {
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}`);
@@ -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: just add it to MIXIN_PIPELINE at the appropriate position.
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
- * @returns The fully composed OxyServices class with all mixins applied
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
- return MIXIN_PIPELINE.reduce((Base, mixin) => mixin(Base), OxyServicesBase);
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.name?.full || userData.name?.first || userData.username || 'Account';
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;
@@ -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
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
19
- const moduleName = '@react-native-async-storage/async-storage';
20
- const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
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),