@oxyhq/core 1.11.12 → 1.11.14

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 +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 +10 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +147 -14
  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 +416 -110
  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/languageUtils.js +22 -0
  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 +215 -34
  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 +4 -3
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +145 -14
  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 +59 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +416 -77
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/deviceManager.js +5 -3
  60. package/dist/esm/utils/languageUtils.js +21 -0
  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 +50 -7
  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 +7 -5
  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 +82 -5
  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 +28 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +145 -10
  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/languageUtils.d.ts +1 -0
  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 +45 -2
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +251 -40
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +26 -7
  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 -30
  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 +19 -4
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +175 -15
  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 +72 -49
  122. package/src/mixins/OxyServices.utility.ts +562 -89
  123. package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
  124. package/src/mixins/index.ts +58 -7
  125. package/src/models/interfaces.ts +65 -3
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/deviceManager.ts +7 -4
  128. package/src/utils/languageUtils.ts +23 -2
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Authentication Methods Mixin
3
- *
3
+ *
4
4
  * Supports password-based login (email/username) and public key challenge-response.
5
5
  */
6
6
  import type { User } from '../models/interfaces';
7
7
  import type { SessionLoginResponse } from '../models/session';
8
8
  import type { OxyServicesBase } from '../OxyServices.base';
9
9
  import { OxyAuthenticationError } from '../OxyServices.errors';
10
+ import { loadNodeCrypto } from '../utils/platformCrypto';
11
+ import { logger } from '../utils/loggerUtils';
10
12
 
11
13
  export interface ChallengeResponse {
12
14
  challenge: string;
@@ -41,36 +43,112 @@ export interface ServiceTokenResponse {
41
43
  appName: string;
42
44
  }
43
45
 
46
+ /**
47
+ * One cache entry per (apiKey hash) → issued token + the secret that produced it.
48
+ * The secret is kept around in raw Buffer form so we can perform a
49
+ * constant-time compare against any reused credential pair — this prevents an
50
+ * attacker who learned a victim's apiKey from receiving the victim's cached
51
+ * service token by simply guessing the secret.
52
+ *
53
+ * @internal
54
+ */
55
+ interface ServiceTokenCacheEntry {
56
+ token: string;
57
+ /** Expiry as ms since epoch */
58
+ expiresAt: number;
59
+ /** Raw secret stored as Buffer for constant-time comparison on cache hit */
60
+ secretBuf: Buffer;
61
+ /** In-flight refresh promise (deduplicates concurrent callers) */
62
+ pending: Promise<string> | null;
63
+ }
64
+
65
+ /**
66
+ * Sentinel error raised when getServiceToken() is called with a known apiKey
67
+ * but a non-matching secret. Indicates either credential drift in the caller
68
+ * or a cross-tenant cache lookup attempt. Surface as a 401-equivalent.
69
+ */
70
+ export class ServiceCredentialMismatchError extends Error {
71
+ constructor() {
72
+ super('Service credential mismatch: provided secret does not match the secret stored for this apiKey');
73
+ this.name = 'ServiceCredentialMismatchError';
74
+ }
75
+ }
76
+
44
77
  export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
45
78
  return class extends Base {
46
- /** @internal */ _serviceToken: string | null = null;
47
- /** @internal */ _serviceTokenExp: number = 0;
48
- /** @internal */ _serviceApiKey: string | null = null;
49
- /** @internal */ _serviceApiSecret: string | null = null;
79
+ /**
80
+ * Per-credential token cache.
81
+ *
82
+ * Keyed by SHA-256(apiKey). Each entry carries:
83
+ * - the issued service JWT
84
+ * - its expiry timestamp
85
+ * - the secret that produced it (Buffer for constant-time compare)
86
+ * - an optional in-flight promise to deduplicate concurrent refreshes
87
+ *
88
+ * The previous implementation kept ONE token/exp pair per OxyServices
89
+ * instance. That meant calling `getServiceToken(keyA, secretA)` populated
90
+ * the cache, and a subsequent `getServiceToken(keyB, secretB)` (different
91
+ * tenant) would receive tenant A's token. This is fixed by routing every
92
+ * lookup through the Map.
93
+ *
94
+ * @internal
95
+ */
96
+ _serviceTokenCache = new Map<string, ServiceTokenCacheEntry>();
97
+
98
+ /** @internal Raw apiKey stored by configureServiceAuth() for use by getServiceToken() */
99
+ _serviceApiKey: string | null = null;
100
+ /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
101
+ _serviceApiSecret: string | null = null;
50
102
 
51
103
  constructor(...args: any[]) {
52
104
  super(...(args as [any]));
53
105
  }
54
106
 
107
+ /**
108
+ * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
109
+ * tokens are only ever issued by a Node host (the SDK on web/RN never has
110
+ * the apiSecret in the first place), so we can rely on Node crypto here.
111
+ *
112
+ * @internal
113
+ */
114
+ async _hashApiKey(apiKey: string): Promise<string> {
115
+ const nodeCrypto = await loadNodeCrypto();
116
+ return nodeCrypto.createHash('sha256').update(apiKey).digest('hex');
117
+ }
118
+
55
119
  /**
56
120
  * Configure service credentials for internal service-to-service communication.
57
121
  * Call this once at startup so that getServiceToken() and makeServiceRequest()
58
122
  * can automatically obtain and refresh tokens.
59
123
  *
124
+ * Calling this with credentials that differ from a previously-configured pair
125
+ * is allowed — each `(apiKey, apiSecret)` pair is cached independently, so
126
+ * legitimate multi-tenant hosts that need to switch credentials cannot leak
127
+ * one tenant's token to another tenant on the same instance.
128
+ *
60
129
  * @param apiKey - DeveloperApp API key (oxy_dk_*)
61
130
  * @param apiSecret - DeveloperApp API secret
62
131
  */
63
132
  configureServiceAuth(apiKey: string, apiSecret: string): void {
64
133
  this._serviceApiKey = apiKey;
65
134
  this._serviceApiSecret = apiSecret;
66
- // Invalidate any cached token
67
- this._serviceToken = null;
68
- this._serviceTokenExp = 0;
69
135
  }
70
136
 
71
137
  /**
72
138
  * Get a service token for internal service-to-service communication.
73
- * Tokens are short-lived (1h) and automatically cached/refreshed.
139
+ * Tokens are short-lived (1h) and automatically cached/refreshed per
140
+ * `(apiKey, apiSecret)` pair.
141
+ *
142
+ * Concurrent callers for the same credential pair share a single in-flight
143
+ * request to avoid hammering `/auth/service-token` when the cache is empty
144
+ * or expired.
145
+ *
146
+ * **Security guarantee:** if the cache already holds a token for this
147
+ * apiKey but the supplied apiSecret does not constant-time match the
148
+ * secret that originally produced that token, this method throws
149
+ * `ServiceCredentialMismatchError` instead of returning the cached token.
150
+ * This prevents an attacker who learned a peer's apiKey from extracting
151
+ * their service token by polling with a wrong secret.
74
152
  *
75
153
  * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
76
154
  * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
@@ -83,11 +161,79 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
83
161
  throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
84
162
  }
85
163
 
86
- // Return cached token if still valid (with 60s buffer)
87
- if (this._serviceToken && this._serviceTokenExp > Date.now() + 60_000) {
88
- return this._serviceToken;
164
+ const cacheKey = await this._hashApiKey(key);
165
+ const now = Date.now();
166
+ const providedSecretBuf = Buffer.from(secret, 'utf8');
167
+
168
+ let entry = this._serviceTokenCache.get(cacheKey);
169
+
170
+ // Verify the secret on every cache hit, regardless of token freshness.
171
+ // Constant-time compare prevents timing oracles on the stored secret.
172
+ if (entry) {
173
+ const nodeCrypto = await loadNodeCrypto();
174
+ const storedSecretBuf = entry.secretBuf;
175
+ const lengthMatch = storedSecretBuf.length === providedSecretBuf.length;
176
+ // Always run timingSafeEqual on equal-length inputs to keep timing flat.
177
+ // When lengths differ, run against a zero-padded copy of the same length
178
+ // to avoid an early-return timing signal.
179
+ const compareBuf = lengthMatch
180
+ ? providedSecretBuf
181
+ : Buffer.alloc(storedSecretBuf.length);
182
+ const compareResult = nodeCrypto.timingSafeEqual(storedSecretBuf, compareBuf);
183
+ if (!lengthMatch || !compareResult) {
184
+ logger.warn('[oxy.auth] Service token cache hit with mismatched secret', {
185
+ component: 'auth',
186
+ method: 'getServiceToken',
187
+ });
188
+ throw new ServiceCredentialMismatchError();
189
+ }
190
+
191
+ // Return cached token if still valid (with 60s buffer for clock drift)
192
+ if (entry.token && entry.expiresAt > now + 60_000) {
193
+ return entry.token;
194
+ }
195
+
196
+ // If a fetch is already in-flight for this credential, share its result
197
+ if (entry.pending) {
198
+ return entry.pending;
199
+ }
200
+ } else {
201
+ // First time seeing this apiKey on this instance — seed an empty entry
202
+ // so concurrent callers serialize on the same promise.
203
+ entry = {
204
+ token: '',
205
+ expiresAt: 0,
206
+ secretBuf: providedSecretBuf,
207
+ pending: null,
208
+ };
209
+ this._serviceTokenCache.set(cacheKey, entry);
210
+ }
211
+
212
+ const pending = this._doFetchServiceToken(key, secret, cacheKey, providedSecretBuf);
213
+ entry.pending = pending;
214
+ try {
215
+ return await pending;
216
+ } finally {
217
+ // Clear the in-flight slot; the entry itself (with fresh token / expiry)
218
+ // is updated inside _doFetchServiceToken before we land here.
219
+ const settled = this._serviceTokenCache.get(cacheKey);
220
+ if (settled) {
221
+ settled.pending = null;
222
+ }
89
223
  }
224
+ }
90
225
 
226
+ /**
227
+ * Perform the actual /auth/service-token request and cache the result.
228
+ * Separated so getServiceToken() can deduplicate concurrent calls.
229
+ * @internal
230
+ */
231
+ async _doFetchServiceToken(
232
+ key: string,
233
+ secret: string,
234
+ cacheKey: string,
235
+ secretBuf: Buffer,
236
+ ): Promise<string> {
91
237
  const response = await this.makeRequest<ServiceTokenResponse>(
92
238
  'POST',
93
239
  '/auth/service-token',
@@ -95,10 +241,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
95
241
  { cache: false, retry: false }
96
242
  );
97
243
 
98
- this._serviceToken = response.token;
99
- this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
244
+ const expiresAt = Date.now() + response.expiresIn * 1000;
245
+ // Update the entry in-place so any caller that already grabbed a reference
246
+ // (via `_serviceTokenCache.get(...)`) sees the fresh state.
247
+ const entry = this._serviceTokenCache.get(cacheKey);
248
+ if (entry) {
249
+ entry.token = response.token;
250
+ entry.expiresAt = expiresAt;
251
+ entry.secretBuf = secretBuf;
252
+ } else {
253
+ this._serviceTokenCache.set(cacheKey, {
254
+ token: response.token,
255
+ expiresAt,
256
+ secretBuf,
257
+ pending: null,
258
+ });
259
+ }
100
260
 
101
- return this._serviceToken;
261
+ return response.token;
102
262
  }
103
263
 
104
264
  /**
@@ -0,0 +1,73 @@
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
+
22
+ import type { OxyServicesBase } from '../OxyServices.base';
23
+
24
+ /** A single match returned by `POST /contacts/discover`. */
25
+ export interface ContactDiscoveryMatch {
26
+ /** Oxy user ID (MongoDB ObjectId hex string). */
27
+ userId: string;
28
+ /** The hashed identifier from the request that matched this user. */
29
+ hashedIdentifier: string;
30
+ /** Whether the match came from the email index or phone index. */
31
+ matchType: 'email' | 'phone';
32
+ }
33
+
34
+ /** Response shape of `POST /contacts/discover`. */
35
+ export interface ContactDiscoveryResponse {
36
+ matches: ContactDiscoveryMatch[];
37
+ }
38
+
39
+ export function OxyServicesContactsMixin<T extends typeof OxyServicesBase>(Base: T) {
40
+ return class extends Base {
41
+ constructor(...args: any[]) {
42
+ super(...(args as [any]));
43
+ }
44
+
45
+ /**
46
+ * Discover which of the caller's contacts are on Oxy.
47
+ *
48
+ * @param hashedEmails - SHA-256 hex digests of normalized emails.
49
+ * @param hashedPhones - SHA-256 hex digests of normalized phone numbers.
50
+ * @returns Matches mapping each hashed identifier to the Oxy user ID it
51
+ * resolved to. Empty arrays are valid for either parameter, but at
52
+ * least one must be non-empty.
53
+ *
54
+ * The server enforces a 200-hash cap per channel per request — callers
55
+ * should batch larger address books client-side.
56
+ */
57
+ async discoverContacts(
58
+ hashedEmails: string[],
59
+ hashedPhones: string[],
60
+ ): Promise<ContactDiscoveryResponse> {
61
+ try {
62
+ return await this.makeRequest<ContactDiscoveryResponse>(
63
+ 'POST',
64
+ '/contacts/discover',
65
+ { hashedEmails, hashedPhones },
66
+ { cache: false },
67
+ );
68
+ } catch (error) {
69
+ throw this.handleError(error);
70
+ }
71
+ }
72
+ };
73
+ }
@@ -412,17 +412,7 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
412
412
  }
413
413
  }
414
414
 
415
- // ==================
416
- // ACCOUNT
417
- // ==================
418
-
419
- /**
420
- * Delete user account (requires password confirmation)
421
- */
422
- async deleteAccount(password: string): Promise<void> {
423
- return this.withAuthRetry(async () => {
424
- await this.makeRequest('DELETE', '/account', { password }, { cache: false });
425
- }, 'deleteAccount');
426
- }
415
+ // Account deletion lives in OxyServices.user mixin — it requires
416
+ // an identity-key signature (not just a password) and hits DELETE /users/me.
427
417
  };
428
418
  }
@@ -372,10 +372,11 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
372
372
  {
373
373
  configURL: options.configURL,
374
374
  clientId: options.clientId,
375
- // Send nonce at both levels for backward compatibility
376
- nonce: options.nonce, // For older browsers
375
+ // Older browsers read `nonce` at the top level; Chrome 145+
376
+ // expects it inside `params`. Send both for full coverage.
377
+ nonce: options.nonce,
377
378
  params: {
378
- nonce: options.nonce, // For Chrome 145+
379
+ nonce: options.nonce,
379
380
  },
380
381
  ...(options.loginHint && { loginHint: options.loginHint }),
381
382
  },
@@ -4,6 +4,7 @@
4
4
  import { normalizeLanguageCode, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils';
5
5
  import type { LanguageMetadata } from '../utils/languageUtils';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
+ import { loadAsyncStorage } from '../utils/platformCrypto';
7
8
  import { isDev } from '../shared/utils/debugUtils';
8
9
 
9
10
  export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base: T) {
@@ -23,10 +24,11 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
23
24
 
24
25
  if (isReactNative) {
25
26
  try {
26
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
27
- const moduleName = '@react-native-async-storage/async-storage';
28
- const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
29
- const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
27
+ // `loadAsyncStorage` is per-platform: the RN variant statically imports
28
+ // @react-native-async-storage/async-storage, the default variant throws
29
+ // (never called outside RN because of the `isReactNative` gate above).
30
+ const asyncStorageModule = await loadAsyncStorage();
31
+ const storage = asyncStorageModule.default;
30
32
  return {
31
33
  getItem: storage.getItem.bind(storage),
32
34
  setItem: storage.setItem.bind(storage),
@@ -177,12 +177,16 @@ export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(B
177
177
  this.storeTokens(accessToken, sessionId);
178
178
  this.httpService.setTokens(accessToken);
179
179
 
180
- // Build session response (minimal - we'll fetch full user data separately)
180
+ // Build session response (minimal full user data is fetched separately
181
+ // by the caller via getCurrentUser() once tokens are stored).
181
182
  const session: SessionLoginResponse = {
182
183
  sessionId,
183
184
  deviceId: '', // Not available in redirect flow
184
185
  expiresAt: expiresAt || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
185
- user: {} as any, // Will be fetched separately
186
+ // Placeholder user caller MUST fetch real user data via getCurrentUser()
187
+ // before exposing this session to the application. The empty id signals
188
+ // that the user payload has not yet been populated.
189
+ user: { id: '', username: '' },
186
190
  };
187
191
 
188
192
  // Clean up URL (remove auth parameters)
@@ -24,19 +24,29 @@ export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base:
24
24
  eventType?: SecurityEventType
25
25
  ): Promise<SecurityActivityResponse> {
26
26
  try {
27
- const params: any = {};
27
+ const params: Record<string, unknown> = {};
28
28
  if (limit !== undefined) params.limit = limit;
29
29
  if (offset !== undefined) params.offset = offset;
30
30
  if (eventType) params.eventType = eventType;
31
31
 
32
- const response = await this.makeRequest<SecurityActivityResponse>(
33
- 'GET',
34
- '/security/activity',
35
- params,
36
- { cache: false }
37
- );
32
+ // The API responds with the standard paginated envelope:
33
+ // { data: SecurityActivity[], pagination: { total, limit, offset, hasMore } }
34
+ // SecurityActivityResponse is the flattened shape consumers expect.
35
+ const raw = await this.makeRequest<{
36
+ data: SecurityActivity[];
37
+ pagination: { total: number; limit: number; offset: number; hasMore: boolean };
38
+ }>('GET', '/security/activity', params, { cache: false });
39
+
40
+ const requestedLimit = typeof params.limit === 'number' ? params.limit : 0;
41
+ const requestedOffset = typeof params.offset === 'number' ? params.offset : 0;
38
42
 
39
- return response;
43
+ return {
44
+ data: raw.data ?? [],
45
+ total: raw.pagination?.total ?? raw.data?.length ?? 0,
46
+ limit: raw.pagination?.limit ?? requestedLimit,
47
+ offset: raw.pagination?.offset ?? requestedOffset,
48
+ hasMore: raw.pagination?.hasMore ?? false,
49
+ };
40
50
  } catch (error) {
41
51
  throw this.handleError(error);
42
52
  }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * User Management Methods Mixin
3
3
  */
4
- import type { User, Notification, SearchProfilesResponse, PaginationInfo } from '../models/interfaces';
4
+ import type { User, Notification, SearchProfilesResponse, PaginationInfo, PrivacySettings } from '../models/interfaces';
5
5
  import type { OxyServicesBase } from '../OxyServices.base';
6
6
  import { buildSearchParams, buildPaginationParams, type PaginationParams } from '../utils/apiUtils';
7
+ import { KeyManager } from '../crypto/keyManager';
8
+ import { SignatureService } from '../crypto/signatureService';
7
9
 
8
10
  export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
9
11
  return class extends Base {
@@ -51,7 +53,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
51
53
  const searchParams = buildSearchParams(params);
52
54
  const paramsObj = Object.fromEntries(searchParams.entries());
53
55
 
54
- const response = await this.makeRequest<SearchProfilesResponse | User[]>(
56
+ const response = await this.makeRequest<SearchProfilesResponse>(
55
57
  'GET',
56
58
  '/profiles/search',
57
59
  paramsObj,
@@ -61,43 +63,26 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
61
63
  }
62
64
  );
63
65
 
64
- // New API shape: { data: User[], pagination: {...} }
65
- const isSearchProfilesResponse = (payload: unknown): payload is SearchProfilesResponse =>
66
- typeof payload === 'object' &&
67
- payload !== null &&
68
- Array.isArray((payload as SearchProfilesResponse).data);
69
-
70
- if (isSearchProfilesResponse(response)) {
71
- const typedResponse = response;
72
- const paginationInfo: PaginationInfo = typedResponse.pagination ?? {
73
- total: typedResponse.data.length,
74
- limit: pagination?.limit ?? typedResponse.data.length,
75
- offset: pagination?.offset ?? 0,
76
- hasMore: typedResponse.data.length === (pagination?.limit ?? typedResponse.data.length) &&
77
- (pagination?.limit ?? typedResponse.data.length) > 0,
78
- };
79
-
80
- return {
81
- data: typedResponse.data,
82
- pagination: paginationInfo,
83
- };
66
+ if (
67
+ typeof response !== 'object' ||
68
+ response === null ||
69
+ !Array.isArray(response.data)
70
+ ) {
71
+ throw new Error('Unexpected search response format');
84
72
  }
85
73
 
86
- // Legacy API shape: returns raw User[]
87
- if (Array.isArray(response)) {
88
- const fallbackLimit = pagination?.limit ?? response.length;
89
- const fallbackPagination: PaginationInfo = {
90
- total: response.length,
91
- limit: fallbackLimit,
92
- offset: pagination?.offset ?? 0,
93
- hasMore: fallbackLimit > 0 && response.length === fallbackLimit,
94
- };
95
-
96
- return { data: response, pagination: fallbackPagination };
97
- }
74
+ const paginationInfo: PaginationInfo = response.pagination ?? {
75
+ total: response.data.length,
76
+ limit: pagination?.limit ?? response.data.length,
77
+ offset: pagination?.offset ?? 0,
78
+ hasMore: response.data.length === (pagination?.limit ?? response.data.length) &&
79
+ (pagination?.limit ?? response.data.length) > 0,
80
+ };
98
81
 
99
- // If response is unexpected, throw an error
100
- throw new Error('Unexpected search response format');
82
+ return {
83
+ data: response.data,
84
+ pagination: paginationInfo,
85
+ };
101
86
  } catch (error) {
102
87
  throw this.handleError(error);
103
88
  }
@@ -206,12 +191,33 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
206
191
  }
207
192
 
208
193
  /**
209
- * Update user profile
210
- * TanStack Query handles offline queuing automatically
194
+ * Update user profile.
195
+ *
196
+ * Invalidates the SDK-side response cache for every endpoint that
197
+ * returns the current user (`GET /users/me`, `GET /session/user/*`,
198
+ * `GET /users/<id>`, `GET /profiles/username/*`) so the next read
199
+ * doesn't return a stale snapshot. Without this, a follow-up
200
+ * `getUserBySession` call inside the 2-minute cache window can return
201
+ * the pre-update user — most visibly during onboarding, where it
202
+ * causes the username step to flicker back as if nothing was saved.
203
+ *
204
+ * TanStack Query handles offline queuing automatically.
211
205
  */
212
- async updateProfile(updates: Record<string, any>): Promise<User> {
206
+ async updateProfile(updates: Partial<User>): Promise<User> {
213
207
  try {
214
- return await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false });
208
+ const result = await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false });
209
+
210
+ // Bust every cached representation of the current user. We use a
211
+ // prefix sweep rather than an enumeration because the SDK never
212
+ // tracks the set of active session IDs centrally.
213
+ this.clearCacheByPrefix('GET:/session/user/');
214
+ this.clearCacheByPrefix('GET:/users/me');
215
+ this.clearCacheByPrefix('GET:/profiles/username/');
216
+ if (result?.id) {
217
+ this.clearCacheEntry(`GET:/users/${result.id}`);
218
+ }
219
+
220
+ return result;
215
221
  } catch (error) {
216
222
  const errorAny = error as any;
217
223
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -237,10 +243,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
237
243
  * Get privacy settings for a user
238
244
  * @param userId - The user ID (defaults to current user)
239
245
  */
240
- async getPrivacySettings(userId?: string): Promise<any> {
246
+ async getPrivacySettings(userId?: string): Promise<PrivacySettings> {
241
247
  try {
242
248
  const id = userId || (await this.getCurrentUser()).id;
243
- return await this.makeRequest<any>('GET', `/privacy/${id}/privacy`, undefined, {
249
+ return await this.makeRequest<PrivacySettings>('GET', `/privacy/${id}/privacy`, undefined, {
244
250
  cache: true,
245
251
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
246
252
  });
@@ -254,10 +260,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
254
260
  * @param settings - Partial privacy settings object
255
261
  * @param userId - The user ID (defaults to current user)
256
262
  */
257
- async updatePrivacySettings(settings: Record<string, any>, userId?: string): Promise<any> {
263
+ async updatePrivacySettings(settings: Partial<PrivacySettings>, userId?: string): Promise<PrivacySettings> {
258
264
  try {
259
265
  const id = userId || (await this.getCurrentUser()).id;
260
- return await this.makeRequest<any>('PATCH', `/privacy/${id}/privacy`, settings, {
266
+ return await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
261
267
  cache: false,
262
268
  });
263
269
  } catch (error) {
@@ -299,14 +305,31 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
299
305
  }
300
306
 
301
307
  /**
302
- * Delete account permanently
303
- * @param password - User password for confirmation
304
- * @param confirmText - Confirmation text (usually username)
308
+ * Delete account permanently.
309
+ *
310
+ * Signs `delete:{publicKey}:{timestamp}` with the locally-stored identity
311
+ * private key and submits the signature alongside the confirmation text
312
+ * (must equal the user's username). The signature is the cryptographic
313
+ * proof of ownership — only the device holding the private key can issue
314
+ * a valid signature, so no password is required.
315
+ *
316
+ * @param confirmText - Must equal the user's username (verified server-side)
317
+ * @throws If no identity is stored on this device, or signing fails
305
318
  */
306
- async deleteAccount(password: string, confirmText: string): Promise<{ message: string }> {
319
+ async deleteAccount(confirmText: string): Promise<{ message: string }> {
307
320
  try {
321
+ const publicKey = await KeyManager.getPublicKey();
322
+ if (!publicKey) {
323
+ throw new Error('No identity found on this device. Account deletion requires the device that holds your identity key.');
324
+ }
325
+
326
+ const timestamp = Date.now();
327
+ const message = `delete:${publicKey}:${timestamp}`;
328
+ const signature = await SignatureService.sign(message);
329
+
308
330
  return await this.makeRequest<{ message: string }>('DELETE', '/users/me', {
309
- password,
331
+ signature,
332
+ timestamp,
310
333
  confirmText,
311
334
  }, { cache: false });
312
335
  } catch (error) {