@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.
Files changed (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +227 -51
  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 +70 -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/asyncUtils.js +34 -5
  29. package/dist/cjs/utils/deviceManager.js +5 -36
  30. package/dist/cjs/utils/platformCrypto.js +165 -0
  31. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/CrossDomainAuth.js +3 -1
  34. package/dist/esm/HttpService.js +228 -52
  35. package/dist/esm/OxyServices.base.js +9 -0
  36. package/dist/esm/OxyServices.js +8 -3
  37. package/dist/esm/crypto/index.js +1 -1
  38. package/dist/esm/crypto/keyManager.js +473 -138
  39. package/dist/esm/crypto/polyfill.js +14 -32
  40. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  41. package/dist/esm/crypto/signatureService.js +25 -27
  42. package/dist/esm/i18n/locales/en-US.json +46 -1
  43. package/dist/esm/i18n/locales/es-ES.json +46 -1
  44. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  45. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  46. package/dist/esm/index.js +2 -2
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +27 -0
  49. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  50. package/dist/esm/mixins/OxyServices.features.js +0 -11
  51. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  52. package/dist/esm/mixins/OxyServices.language.js +5 -3
  53. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  54. package/dist/esm/mixins/OxyServices.security.js +13 -2
  55. package/dist/esm/mixins/OxyServices.user.js +70 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +19 -10
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/asyncUtils.js +34 -5
  60. package/dist/esm/utils/deviceManager.js +5 -3
  61. package/dist/esm/utils/platformCrypto.js +125 -0
  62. package/dist/esm/utils/platformCrypto.native.js +80 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/HttpService.d.ts +47 -3
  65. package/dist/types/OxyServices.base.d.ts +7 -0
  66. package/dist/types/OxyServices.d.ts +36 -3
  67. package/dist/types/crypto/index.d.ts +1 -1
  68. package/dist/types/crypto/keyManager.d.ts +110 -9
  69. package/dist/types/crypto/polyfill.d.ts +3 -1
  70. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  71. package/dist/types/crypto/signatureService.d.ts +4 -0
  72. package/dist/types/index.d.ts +4 -3
  73. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  75. package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
  76. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  77. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  80. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.user.d.ts +40 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  93. package/dist/types/mixins/index.d.ts +52 -4
  94. package/dist/types/models/interfaces.d.ts +62 -3
  95. package/dist/types/utils/accountUtils.d.ts +41 -1
  96. package/dist/types/utils/asyncUtils.d.ts +6 -2
  97. package/dist/types/utils/platformCrypto.d.ts +87 -0
  98. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  99. package/package.json +28 -1
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +264 -51
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +9 -4
  104. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  105. package/src/crypto/index.ts +6 -1
  106. package/src/crypto/keyManager.ts +529 -151
  107. package/src/crypto/polyfill.ts +14 -34
  108. package/src/crypto/recoveryPhrase.ts +56 -17
  109. package/src/crypto/signatureService.ts +25 -29
  110. package/src/i18n/locales/en-US.json +46 -1
  111. package/src/i18n/locales/es-ES.json +46 -1
  112. package/src/index.ts +16 -3
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +28 -0
  115. package/src/mixins/OxyServices.contacts.ts +73 -0
  116. package/src/mixins/OxyServices.features.ts +2 -12
  117. package/src/mixins/OxyServices.fedcm.ts +4 -3
  118. package/src/mixins/OxyServices.language.ts +6 -4
  119. package/src/mixins/OxyServices.redirect.ts +6 -2
  120. package/src/mixins/OxyServices.security.ts +18 -8
  121. package/src/mixins/OxyServices.user.ts +90 -49
  122. package/src/mixins/OxyServices.utility.ts +19 -10
  123. package/src/mixins/index.ts +58 -7
  124. package/src/models/interfaces.ts +65 -3
  125. package/src/utils/__tests__/asyncUtils.test.ts +187 -0
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/asyncUtils.ts +39 -9
  128. package/src/utils/deviceManager.ts +7 -4
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -5,10 +5,13 @@
5
5
  * across all platforms (Node.js, Browser, React Native).
6
6
  *
7
7
  * - Browser/Node.js: Uses native crypto
8
- * - React Native: Falls back to expo-crypto if native crypto unavailable
8
+ * - React Native: Uses expo-crypto (statically imported via the
9
+ * per-platform `platformCrypto` module — see that file's doc-comment for
10
+ * how platform routing works).
9
11
  */
10
12
 
11
13
  import { Buffer } from 'buffer';
14
+ import { getRandomBytesRN } from '../utils/platformCrypto';
12
15
 
13
16
  const getGlobalObject = (): typeof globalThis => {
14
17
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -29,39 +32,19 @@ type CryptoLike = {
29
32
  getRandomValues: <T extends ArrayBufferView>(array: T) => T;
30
33
  };
31
34
 
32
- // Cache for expo-crypto module (lazy loaded only in React Native)
33
- let expoCryptoModule: { getRandomBytes: (count: number) => Uint8Array } | null = null;
34
- let expoCryptoLoadPromise: Promise<void> | null = null;
35
-
36
35
  /**
37
- * Eagerly start loading expo-crypto. The module is cached once resolved so
38
- * the synchronous getRandomValues shim can read from it immediately.
39
- * Uses dynamic import with variable indirection to prevent ESM bundlers
40
- * (Vite, webpack) from statically resolving the specifier.
36
+ * Synchronous random-bytes shim. On RN, this delegates to
37
+ * `expo-crypto.getRandomBytes` (statically imported by the RN variant of
38
+ * `platformCrypto`, so available without any async warm-up). On Node /
39
+ * browser, this throws but is never called there because both platforms
40
+ * already provide `globalThis.crypto.getRandomValues` natively.
41
41
  */
42
- function startExpoCryptoLoad(): void {
43
- if (expoCryptoLoadPromise) return;
44
- expoCryptoLoadPromise = (async () => {
45
- try {
46
- const moduleName = 'expo-crypto';
47
- expoCryptoModule = await import(moduleName);
48
- } catch {
49
- // expo-crypto not available — expected in non-RN environments
50
- }
51
- })();
52
- }
53
-
54
42
  function getRandomBytesSync(byteCount: number): Uint8Array {
55
- // Kick off loading if not already started (should have been started at module init)
56
- startExpoCryptoLoad();
57
- if (expoCryptoModule) {
58
- return expoCryptoModule.getRandomBytes(byteCount);
59
- }
60
- throw new Error(
61
- 'No crypto.getRandomValues implementation available. ' +
62
- 'In React Native, install expo-crypto. ' +
63
- 'If expo-crypto is installed, ensure the polyfill module is imported early enough for the async load to complete.'
64
- );
43
+ // `getRandomBytesRN` throws on non-RN platforms. That's fine: this
44
+ // function is only ever called as a fallback when the native
45
+ // `globalThis.crypto.getRandomValues` is missing, which on a normal
46
+ // Node/browser host never happens.
47
+ return getRandomBytesRN(byteCount);
65
48
  }
66
49
 
67
50
  const cryptoPolyfill: CryptoLike = {
@@ -75,11 +58,8 @@ const cryptoPolyfill: CryptoLike = {
75
58
 
76
59
  // Only polyfill if crypto or crypto.getRandomValues is not available
77
60
  if (typeof globalObject.crypto === 'undefined') {
78
- // Start loading expo-crypto eagerly so it is ready by the time getRandomValues is called
79
- startExpoCryptoLoad();
80
61
  (globalObject as unknown as { crypto: CryptoLike }).crypto = cryptoPolyfill;
81
62
  } else if (typeof globalObject.crypto.getRandomValues !== 'function') {
82
- startExpoCryptoLoad();
83
63
  (globalObject.crypto as CryptoLike).getRandomValues = cryptoPolyfill.getRandomValues;
84
64
  }
85
65
 
@@ -28,25 +28,48 @@ export interface RecoveryPhraseResult {
28
28
  publicKey: string;
29
29
  }
30
30
 
31
+ export interface GenerateIdentityOptions {
32
+ /**
33
+ * Pass `true` to allow overwriting an existing on-device identity.
34
+ *
35
+ * Defaults to `false`. When false, this method throws
36
+ * `IdentityAlreadyExistsError` if a complete identity already exists,
37
+ * preventing accidental account loss. UI flows MUST only set this to
38
+ * `true` after explicitly confirming the user has saved their previous
39
+ * recovery phrase (or has otherwise been warned).
40
+ */
41
+ overwrite?: boolean;
42
+ }
43
+
31
44
  export class RecoveryPhraseService {
32
45
  /**
33
- * Generate a new identity with a recovery phrase
34
- * Returns the mnemonic phrase (should only be shown once to the user)
46
+ * Generate a new identity with a recovery phrase.
47
+ * The mnemonic phrase MUST be shown to the user exactly once after this
48
+ * call resolves — if it is lost, the account becomes unrecoverable.
49
+ *
50
+ * Refuses to overwrite an existing identity unless `options.overwrite === true`.
51
+ *
52
+ * @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
35
53
  */
36
- static async generateIdentityWithRecovery(): Promise<RecoveryPhraseResult> {
54
+ static async generateIdentityWithRecovery(
55
+ options?: GenerateIdentityOptions,
56
+ ): Promise<RecoveryPhraseResult> {
37
57
  // Generate 128-bit entropy for 12-word mnemonic
38
58
  const mnemonic = bip39.generateMnemonic(128);
39
-
59
+
40
60
  // Derive private key from mnemonic
41
61
  // Using the seed directly as the private key (simplified approach)
42
62
  const seed = await bip39.mnemonicToSeed(mnemonic);
43
-
63
+
44
64
  // Use first 32 bytes of seed as private key
45
65
  const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
46
66
  const privateKeyHex = toHex(seedSlice);
47
-
48
- // Import the derived key pair
49
- const publicKey = await KeyManager.importKeyPair(privateKeyHex);
67
+
68
+ // Import the derived key pair. KeyManager.importKeyPair will refuse to
69
+ // clobber an existing identity unless overwrite is explicitly requested.
70
+ const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
71
+ overwrite: options?.overwrite === true,
72
+ });
50
73
 
51
74
  return {
52
75
  phrase: mnemonic,
@@ -56,16 +79,22 @@ export class RecoveryPhraseService {
56
79
  }
57
80
 
58
81
  /**
59
- * Generate a 24-word recovery phrase for higher security
82
+ * Generate a 24-word recovery phrase for higher security.
83
+ *
84
+ * Same overwrite-protection semantics as `generateIdentityWithRecovery`.
60
85
  */
61
- static async generateIdentityWithRecovery24(): Promise<RecoveryPhraseResult> {
86
+ static async generateIdentityWithRecovery24(
87
+ options?: GenerateIdentityOptions,
88
+ ): Promise<RecoveryPhraseResult> {
62
89
  // Generate 256-bit entropy for 24-word mnemonic
63
90
  const mnemonic = bip39.generateMnemonic(256);
64
-
91
+
65
92
  const seed = await bip39.mnemonicToSeed(mnemonic);
66
93
  const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
67
94
  const privateKeyHex = toHex(seedSlice);
68
- const publicKey = await KeyManager.importKeyPair(privateKeyHex);
95
+ const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
96
+ overwrite: options?.overwrite === true,
97
+ });
69
98
 
70
99
  return {
71
100
  phrase: mnemonic,
@@ -75,12 +104,20 @@ export class RecoveryPhraseService {
75
104
  }
76
105
 
77
106
  /**
78
- * Restore an identity from a recovery phrase
107
+ * Restore an identity from a recovery phrase.
108
+ *
109
+ * Refuses to overwrite a DIFFERENT existing identity unless
110
+ * `options.overwrite === true`. Re-importing the same phrase that
111
+ * matches the current identity is always allowed (it's a no-op refresh
112
+ * of the backup record).
79
113
  */
80
- static async restoreFromPhrase(phrase: string): Promise<string> {
114
+ static async restoreFromPhrase(
115
+ phrase: string,
116
+ options?: GenerateIdentityOptions,
117
+ ): Promise<string> {
81
118
  // Normalize and validate the phrase
82
119
  const normalizedPhrase = phrase.trim().toLowerCase();
83
-
120
+
84
121
  if (!bip39.validateMnemonic(normalizedPhrase)) {
85
122
  throw new Error('Invalid recovery phrase. Please check the words and try again.');
86
123
  }
@@ -89,9 +126,11 @@ export class RecoveryPhraseService {
89
126
  const seed = await bip39.mnemonicToSeed(normalizedPhrase);
90
127
  const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
91
128
  const privateKeyHex = toHex(seedSlice);
92
-
129
+
93
130
  // Import and store the key pair
94
- const publicKey = await KeyManager.importKeyPair(privateKeyHex);
131
+ const publicKey = await KeyManager.importKeyPair(privateKeyHex, {
132
+ overwrite: options?.overwrite === true,
133
+ });
95
134
 
96
135
  return publicKey;
97
136
  }
@@ -8,44 +8,32 @@
8
8
  import { ec as EC } from 'elliptic';
9
9
  import { KeyManager } from './keyManager';
10
10
  import { isReactNative, isNodeJS } from '../utils/platform';
11
-
12
- // Lazy import for expo-crypto
13
- let ExpoCrypto: typeof import('expo-crypto') | null = null;
11
+ import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto';
12
+ import { logger } from '../utils/loggerUtils';
13
+ import { isDev } from '../shared/utils/debugUtils';
14
14
 
15
15
  const ec = new EC('secp256k1');
16
16
 
17
- /**
18
- * Initialize expo-crypto module
19
- */
20
- async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
21
- if (!ExpoCrypto) {
22
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
23
- const moduleName = 'expo-crypto';
24
- ExpoCrypto = await import(moduleName);
25
- }
26
- return ExpoCrypto!;
27
- }
28
-
29
17
  /**
30
18
  * Compute SHA-256 hash of a string
31
19
  */
32
20
  async function sha256(message: string): Promise<string> {
33
21
  // In React Native, use expo-crypto
34
22
  if (isReactNative()) {
35
- const Crypto = await initExpoCrypto();
23
+ const Crypto = await loadExpoCrypto();
36
24
  return Crypto.digestStringAsync(
37
25
  Crypto.CryptoDigestAlgorithm.SHA256,
38
26
  message
39
27
  );
40
28
  }
41
29
 
42
- // In Node.js, use Node's crypto module
43
30
  if (isNodeJS()) {
44
31
  try {
45
- const nodeCrypto = await import('crypto');
32
+ const nodeCrypto = await loadNodeCrypto();
46
33
  return nodeCrypto.createHash('sha256').update(message).digest('hex');
47
- } catch {
48
- // Fall through to Web Crypto API
34
+ } catch (error) {
35
+ // Node crypto failed to load — log and fall through to Web Crypto API
36
+ logger.warn('[oxy.crypto] Node crypto unavailable, falling back to Web Crypto', { component: 'SignatureService' }, error);
49
37
  }
50
38
  }
51
39
 
@@ -78,22 +66,20 @@ export class SignatureService {
78
66
  static async generateChallenge(): Promise<string> {
79
67
  // In React Native, use expo-crypto
80
68
  if (isReactNative()) {
81
- const Crypto = await initExpoCrypto();
69
+ const Crypto = await loadExpoCrypto();
82
70
  const randomBytes = await Crypto.getRandomBytesAsync(32);
83
71
  return Array.from(new Uint8Array(randomBytes))
84
72
  .map((b) => b.toString(16).padStart(2, '0'))
85
73
  .join('');
86
74
  }
87
75
 
88
- // In Node.js, use Node's crypto module
89
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
90
76
  if (isNodeJS()) {
91
77
  try {
92
- const cryptoModuleName = 'crypto';
93
- const nodeCrypto = await import(cryptoModuleName);
78
+ const nodeCrypto = await loadNodeCrypto();
94
79
  return nodeCrypto.randomBytes(32).toString('hex');
95
- } catch {
96
- // Fall through to Web Crypto API
80
+ } catch (error) {
81
+ // Node crypto failed to load — log and fall through to Web Crypto API
82
+ logger.warn('[oxy.crypto] Node crypto unavailable, falling back to Web Crypto', { component: 'SignatureService' }, error);
97
83
  }
98
84
  }
99
85
 
@@ -140,13 +126,20 @@ export class SignatureService {
140
126
 
141
127
  /**
142
128
  * Verify a signature against a message and public key
129
+ *
130
+ * Returns false on any error (invalid signature, malformed input, etc.).
131
+ * Errors are logged at debug level so they're available when troubleshooting
132
+ * signature mismatches but don't surface to the caller.
143
133
  */
144
134
  static async verify(message: string, signature: string, publicKey: string): Promise<boolean> {
145
135
  try {
146
136
  const key = ec.keyFromPublic(publicKey, 'hex');
147
137
  const messageHash = await sha256(message);
148
138
  return key.verify(messageHash, signature);
149
- } catch {
139
+ } catch (error) {
140
+ if (isDev()) {
141
+ logger.debug('[oxy.crypto] verify() returned false', { component: 'SignatureService' }, error);
142
+ }
150
143
  return false;
151
144
  }
152
145
  }
@@ -172,7 +165,10 @@ export class SignatureService {
172
165
  const key = ec.keyFromPublic(publicKey, 'hex');
173
166
  const messageHash = crypto.createHash('sha256').update(message).digest('hex');
174
167
  return key.verify(messageHash, signature);
175
- } catch {
168
+ } catch (error) {
169
+ if (isDev()) {
170
+ logger.debug('[oxy.crypto] verifySync() returned false', { component: 'SignatureService' }, error);
171
+ }
176
172
  return false;
177
173
  }
178
174
  }
@@ -655,6 +655,21 @@
655
655
  "confirms": {
656
656
  "removeAvatar": "Remove your profile picture?"
657
657
  },
658
+ "crop": {
659
+ "title": "Crop avatar",
660
+ "subtitle": "Pinch to zoom, drag to position",
661
+ "noImage": "No image to crop",
662
+ "reset": "Reset",
663
+ "resetToCenter": "Reset to center",
664
+ "confirm": "Use photo",
665
+ "cancel": "Cancel",
666
+ "saving": "Saving…",
667
+ "helper": "The cropped circle is what will appear on your profile. Pinch to zoom, drag to position.",
668
+ "zoom": "{{value}}×",
669
+ "a11yImage": "Crop preview. Pinch to zoom and drag to reposition the image.",
670
+ "a11yReset": "Reset crop to default position",
671
+ "a11yResetAnnouncement": "Crop reset"
672
+ },
658
673
  "toasts": {
659
674
  "profileUpdated": "Profile updated successfully",
660
675
  "updateFailed": "Failed to update profile",
@@ -664,7 +679,10 @@
664
679
  "avatarSelected": "Avatar selected",
665
680
  "avatarUpdated": "Avatar updated",
666
681
  "updateAvatarFailed": "Failed to update avatar",
667
- "noActiveSession": "No active session"
682
+ "noActiveSession": "No active session",
683
+ "cropMeasureFailed": "Could not measure the image",
684
+ "cropNotReady": "Image not ready yet",
685
+ "cropFailed": "Failed to crop image"
668
686
  }
669
687
  },
670
688
  "accountOverview": {
@@ -1046,6 +1064,8 @@
1046
1064
  "save": "Save",
1047
1065
  "saved": "Saved successfully",
1048
1066
  "saving": "Saving...",
1067
+ "unnamed": "Unnamed",
1068
+ "accountFallback": "Account {{handle}}",
1049
1069
  "links": {
1050
1070
  "recoverAccount": "Recover your account",
1051
1071
  "signUp": "Sign Up"
@@ -1308,9 +1328,34 @@
1308
1328
  "loadingMore": "Loading more...",
1309
1329
  "loadingPhotoLayout": "Loading photo layout...",
1310
1330
  "uploading": "Uploading",
1331
+ "upload": "Upload",
1332
+ "uploadPhoto": "Upload Photo",
1311
1333
  "uploadPhotos": "Upload Photos",
1312
1334
  "uploadFiles": "Upload Files",
1313
1335
  "clearSearch": "Clear Search",
1336
+ "choosePhoto": "Choose Photo",
1337
+ "done": "Done",
1338
+ "doneWithCount": "Done ({{count}})",
1339
+ "photoPicker": {
1340
+ "emptyTitle": "No photos yet",
1341
+ "emptySubtitle": "Upload from your device to get started"
1342
+ },
1343
+ "a11y": {
1344
+ "viewAll": "Show all files",
1345
+ "viewPhotos": "Show photos only",
1346
+ "viewVideos": "Show videos only",
1347
+ "viewDocuments": "Show documents only",
1348
+ "viewAudio": "Show audio only",
1349
+ "sortBy": "Sort by {{field}}, {{order}}",
1350
+ "uploadFile": "Upload file from device",
1351
+ "uploadFromDevice": "Upload photo from device",
1352
+ "photoCellSelected": "Photo {{name}}, selected",
1353
+ "photoCellUnselected": "Photo {{name}}, not selected",
1354
+ "selectionCount": "{{count}} photo selected",
1355
+ "selectionCount_plural": "{{count}} photos selected",
1356
+ "cancelPicker": "Cancel photo selection",
1357
+ "confirmSelection": "Confirm selection"
1358
+ },
1314
1359
  "emptyPhotos": {
1315
1360
  "title": "No Photos Yet",
1316
1361
  "ownDescription": "Upload photos to get started. You can select multiple photos at once.",
@@ -241,6 +241,21 @@
241
241
  "confirms": {
242
242
  "removeAvatar": "¿Eliminar tu foto de perfil?"
243
243
  },
244
+ "crop": {
245
+ "title": "Recortar avatar",
246
+ "subtitle": "Pellizca para acercar, arrastra para colocar",
247
+ "noImage": "No hay imagen para recortar",
248
+ "reset": "Restablecer",
249
+ "resetToCenter": "Restablecer al centro",
250
+ "confirm": "Usar foto",
251
+ "cancel": "Cancelar",
252
+ "saving": "Guardando…",
253
+ "helper": "El círculo recortado es lo que aparecerá en tu perfil. Pellizca para acercar, arrastra para colocar.",
254
+ "zoom": "{{value}}×",
255
+ "a11yImage": "Vista previa del recorte. Pellizca para acercar y arrastra para reposicionar la imagen.",
256
+ "a11yReset": "Restablecer el recorte a la posición predeterminada",
257
+ "a11yResetAnnouncement": "Recorte restablecido"
258
+ },
244
259
  "toasts": {
245
260
  "profileUpdated": "Perfil actualizado correctamente",
246
261
  "updateFailed": "Error al actualizar el perfil",
@@ -250,7 +265,10 @@
250
265
  "avatarSelected": "Avatar seleccionado",
251
266
  "avatarUpdated": "Avatar actualizado",
252
267
  "updateAvatarFailed": "Error al actualizar el avatar",
253
- "noActiveSession": "No hay sesión activa"
268
+ "noActiveSession": "No hay sesión activa",
269
+ "cropMeasureFailed": "No se pudo medir la imagen",
270
+ "cropNotReady": "La imagen aún no está lista",
271
+ "cropFailed": "No se pudo recortar la imagen"
254
272
  }
255
273
  },
256
274
  "common": {
@@ -270,6 +288,8 @@
270
288
  "save": "Guardar",
271
289
  "saved": "Guardado correctamente",
272
290
  "saving": "Guardando...",
291
+ "unnamed": "Sin nombre",
292
+ "accountFallback": "Cuenta {{handle}}",
273
293
  "links": {
274
294
  "recoverAccount": "Recuperar tu cuenta",
275
295
  "signUp": "Registrarse"
@@ -1296,9 +1316,34 @@
1296
1316
  "loadingMore": "Cargando más...",
1297
1317
  "loadingPhotoLayout": "Cargando diseño de fotos...",
1298
1318
  "uploading": "Subiendo",
1319
+ "upload": "Subir",
1320
+ "uploadPhoto": "Subir foto",
1299
1321
  "uploadPhotos": "Subir fotos",
1300
1322
  "uploadFiles": "Subir archivos",
1301
1323
  "clearSearch": "Limpiar búsqueda",
1324
+ "choosePhoto": "Elegir foto",
1325
+ "done": "Listo",
1326
+ "doneWithCount": "Listo ({{count}})",
1327
+ "photoPicker": {
1328
+ "emptyTitle": "Aún no hay fotos",
1329
+ "emptySubtitle": "Sube desde tu dispositivo para empezar"
1330
+ },
1331
+ "a11y": {
1332
+ "viewAll": "Mostrar todos los archivos",
1333
+ "viewPhotos": "Mostrar solo fotos",
1334
+ "viewVideos": "Mostrar solo vídeos",
1335
+ "viewDocuments": "Mostrar solo documentos",
1336
+ "viewAudio": "Mostrar solo audio",
1337
+ "sortBy": "Ordenar por {{field}}, {{order}}",
1338
+ "uploadFile": "Subir archivo desde el dispositivo",
1339
+ "uploadFromDevice": "Subir foto desde el dispositivo",
1340
+ "photoCellSelected": "Foto {{name}}, seleccionada",
1341
+ "photoCellUnselected": "Foto {{name}}, no seleccionada",
1342
+ "selectionCount": "{{count}} foto seleccionada",
1343
+ "selectionCount_plural": "{{count}} fotos seleccionadas",
1344
+ "cancelPicker": "Cancelar selección de foto",
1345
+ "confirmSelection": "Confirmar selección"
1346
+ },
1302
1347
  "emptyPhotos": {
1303
1348
  "title": "Aún no hay fotos",
1304
1349
  "ownDescription": "Sube fotos para empezar. Puedes seleccionar varias fotos a la vez.",
package/src/index.ts CHANGED
@@ -33,9 +33,16 @@ export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
33
33
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
34
34
  export type { ServiceApp } from './mixins/OxyServices.utility';
35
35
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
36
+ export type { ContactDiscoveryMatch, ContactDiscoveryResponse } from './mixins/OxyServices.contacts';
36
37
 
37
38
  // --- Crypto / Identity ---
38
- export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
39
+ export {
40
+ KeyManager,
41
+ SignatureService,
42
+ RecoveryPhraseService,
43
+ IdentityAlreadyExistsError,
44
+ IdentityPersistError,
45
+ } from './crypto';
39
46
  export type { KeyPair, SignedMessage, AuthChallenge, RecoveryPhraseResult } from './crypto';
40
47
 
41
48
  // --- Models & Types ---
@@ -170,8 +177,14 @@ export type { LogContext } from './utils/loggerUtils';
170
177
  export { updateAvatarVisibility } from './utils/avatarUtils';
171
178
 
172
179
  // --- Account Utilities ---
173
- export { buildAccountsArray, createQuickAccount } from './utils/accountUtils';
174
- export type { QuickAccount } from './utils/accountUtils';
180
+ export {
181
+ buildAccountsArray,
182
+ createQuickAccount,
183
+ getAccountDisplayName,
184
+ getAccountFallbackHandle,
185
+ formatPublicKeyHandle,
186
+ } from './utils/accountUtils';
187
+ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
175
188
 
176
189
  // Default export
177
190
  import { OxyServices } from './OxyServices';
@@ -1,4 +1,4 @@
1
- import type { AccountStorageUsageResponse, AssetUrlResponse, AssetVariant } from '../models/interfaces';
1
+ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
 
4
4
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
@@ -155,8 +155,8 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
155
155
  /**
156
156
  * Upload raw file data
157
157
  */
158
- async uploadRawFile(file: File | Blob, visibility?: 'private' | 'public' | 'unlisted', metadata?: Record<string, any>): Promise<any> {
159
- return this.assetUpload(file as File, visibility, metadata);
158
+ async uploadRawFile(file: AssetUploadInput, visibility?: 'private' | 'public' | 'unlisted', metadata?: Record<string, any>): Promise<any> {
159
+ return this.assetUpload(file, visibility, metadata);
160
160
  }
161
161
 
162
162
  /**
@@ -166,20 +166,24 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
166
166
  * ({uri, type, name, size}). RN descriptors are passed directly to
167
167
  * FormData.append, which handles them natively.
168
168
  */
169
- async assetUpload(file: File | { uri: string; type?: string; name?: string; size?: number }, visibility?: 'private' | 'public' | 'unlisted', metadata?: Record<string, any>, onProgress?: (progress: number) => void): Promise<any> {
169
+ async assetUpload(file: AssetUploadInput, visibility?: 'private' | 'public' | 'unlisted', metadata?: Record<string, any>, onProgress?: (progress: number) => void): Promise<any> {
170
170
  const fileName = 'name' in file && file.name ? file.name : 'unknown';
171
171
  const fileSize = 'size' in file && file.size ? file.size : 0;
172
172
 
173
173
  try {
174
174
  const formData = new FormData();
175
175
 
176
- if ('uri' in file && typeof file.uri === 'string') {
177
- // React Native file descriptor — RN's FormData handles {uri, type, name} natively
178
- formData.append('file', file as unknown as Blob, fileName);
179
- } else if (file instanceof Blob) {
176
+ if (typeof File !== 'undefined' && file instanceof File) {
177
+ formData.append('file', file, fileName);
178
+ } else if (typeof Blob !== 'undefined' && file instanceof Blob) {
180
179
  formData.append('file', file, fileName);
180
+ } else if ('uri' in file && typeof (file as RNFileDescriptor).uri === 'string') {
181
+ // React Native file descriptor — RN's FormData handles {uri, type, name} natively.
182
+ // It reads the file from disk during the multipart request — no in-JS Blob
183
+ // conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
184
+ formData.append('file', file as unknown as Blob, fileName);
181
185
  } else {
182
- formData.append('file', new Blob([file as unknown as BlobPart], { type: 'application/octet-stream' }), fileName);
186
+ throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
183
187
  }
184
188
  if (visibility) {
185
189
  formData.append('visibility', visibility);
@@ -350,7 +354,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
350
354
  }
351
355
  }
352
356
 
353
- async uploadAvatar(file: File, userId: string, app: string = 'profiles'): Promise<any> {
357
+ async uploadAvatar(file: AssetUploadInput, userId: string, app: string = 'profiles'): Promise<any> {
354
358
  try {
355
359
  const asset = await this.assetUpload(file, 'public');
356
360
  await this.assetLink(asset.file.id, app, 'avatar', userId, 'public');
@@ -360,7 +364,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
360
364
  }
361
365
  }
362
366
 
363
- async uploadProfileBanner(file: File, userId: string, app: string = 'profiles'): Promise<any> {
367
+ async uploadProfileBanner(file: AssetUploadInput, userId: string, app: string = 'profiles'): Promise<any> {
364
368
  try {
365
369
  const asset = await this.assetUpload(file, 'public');
366
370
  await this.assetLink(asset.file.id, app, 'profile-banner', userId, 'public');
@@ -47,6 +47,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
47
47
  /** @internal */ _serviceTokenExp: number = 0;
48
48
  /** @internal */ _serviceApiKey: string | null = null;
49
49
  /** @internal */ _serviceApiSecret: string | null = null;
50
+ /**
51
+ * In-flight promise for service token fetch. Used to deduplicate concurrent
52
+ * calls to getServiceToken() — pattern mirrors AuthManager.refreshToken().
53
+ * @internal
54
+ */
55
+ _serviceTokenPromise: Promise<string> | null = null;
50
56
 
51
57
  constructor(...args: any[]) {
52
58
  super(...(args as [any]));
@@ -72,6 +78,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
72
78
  * Get a service token for internal service-to-service communication.
73
79
  * Tokens are short-lived (1h) and automatically cached/refreshed.
74
80
  *
81
+ * Concurrent callers share a single in-flight request to avoid hammering
82
+ * `/auth/service-token` when the cache is empty or expired.
83
+ *
75
84
  * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
76
85
  * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
77
86
  */
@@ -88,6 +97,25 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
88
97
  return this._serviceToken;
89
98
  }
90
99
 
100
+ // If a fetch is already in-flight, share the same promise
101
+ if (this._serviceTokenPromise) {
102
+ return this._serviceTokenPromise;
103
+ }
104
+
105
+ this._serviceTokenPromise = this._doFetchServiceToken(key, secret);
106
+ try {
107
+ return await this._serviceTokenPromise;
108
+ } finally {
109
+ this._serviceTokenPromise = null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Perform the actual /auth/service-token request and cache the result.
115
+ * Separated so getServiceToken() can deduplicate concurrent calls.
116
+ * @internal
117
+ */
118
+ async _doFetchServiceToken(key: string, secret: string): Promise<string> {
91
119
  const response = await this.makeRequest<ServiceTokenResponse>(
92
120
  'POST',
93
121
  '/auth/service-token',