@oxyhq/core 3.10.0 → 3.11.0

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 (105) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +103 -0
  8. package/dist/cjs/i18n/locales/en-US.json +9 -0
  9. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  10. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  11. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  12. package/dist/cjs/index.js +15 -5
  13. package/dist/cjs/mixins/OxyServices.assets.js +45 -7
  14. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  15. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  16. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  17. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  18. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  19. package/dist/cjs/mixins/index.js +3 -0
  20. package/dist/cjs/server/cors.js +20 -21
  21. package/dist/cjs/server/rateLimit.js +32 -8
  22. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  23. package/dist/cjs/utils/ssoReturn.js +1 -1
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/AuthManager.js +9 -2
  26. package/dist/esm/HttpService.js +27 -9
  27. package/dist/esm/OxyServices.base.js +3 -2
  28. package/dist/esm/crypto/canonicalJson.js +104 -0
  29. package/dist/esm/crypto/keyManager.js +67 -8
  30. package/dist/esm/crypto/signatureService.js +102 -0
  31. package/dist/esm/i18n/locales/en-US.json +9 -0
  32. package/dist/esm/i18n/locales/es-ES.json +9 -0
  33. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  34. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  35. package/dist/esm/index.js +10 -2
  36. package/dist/esm/mixins/OxyServices.assets.js +45 -7
  37. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  38. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  39. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  40. package/dist/esm/mixins/OxyServices.user.js +1 -0
  41. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  42. package/dist/esm/mixins/index.js +3 -0
  43. package/dist/esm/server/cors.js +20 -21
  44. package/dist/esm/server/rateLimit.js +32 -8
  45. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  46. package/dist/esm/utils/ssoReturn.js +1 -1
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +3 -0
  49. package/dist/types/OxyServices.d.ts +2 -2
  50. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  51. package/dist/types/crypto/keyManager.d.ts +7 -0
  52. package/dist/types/crypto/signatureService.d.ts +61 -0
  53. package/dist/types/index.d.ts +7 -3
  54. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  55. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  56. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  57. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  58. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  59. package/dist/types/mixins/index.d.ts +2 -1
  60. package/dist/types/models/interfaces.d.ts +3 -0
  61. package/dist/types/server/cors.d.ts +5 -5
  62. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  63. package/dist/types/utils/ssoReturn.d.ts +1 -1
  64. package/package.json +3 -2
  65. package/src/AuthManager.ts +8 -2
  66. package/src/HttpService.ts +36 -8
  67. package/src/OxyServices.base.ts +3 -2
  68. package/src/OxyServices.ts +1 -1
  69. package/src/__tests__/authManager.security.test.ts +31 -0
  70. package/src/__tests__/authSocket.test.ts +96 -0
  71. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  72. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  73. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  74. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  75. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  76. package/src/crypto/canonicalJson.ts +120 -0
  77. package/src/crypto/keyManager.ts +62 -12
  78. package/src/crypto/signatureService.ts +126 -0
  79. package/src/i18n/locales/en-US.json +9 -0
  80. package/src/i18n/locales/es-ES.json +9 -0
  81. package/src/index.ts +28 -3
  82. package/src/mixins/OxyServices.assets.ts +56 -7
  83. package/src/mixins/OxyServices.auth.ts +309 -1
  84. package/src/mixins/OxyServices.identity.ts +445 -0
  85. package/src/mixins/OxyServices.sso.ts +30 -1
  86. package/src/mixins/OxyServices.user.ts +1 -0
  87. package/src/mixins/OxyServices.utility.ts +57 -23
  88. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  89. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  90. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  91. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  92. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  93. package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
  94. package/src/mixins/__tests__/sso.test.ts +31 -0
  95. package/src/mixins/index.ts +4 -0
  96. package/src/models/interfaces.ts +3 -0
  97. package/src/server/__tests__/cors.test.ts +5 -1
  98. package/src/server/__tests__/rateLimit.test.ts +116 -0
  99. package/src/server/cors.ts +25 -20
  100. package/src/server/rateLimit.ts +39 -8
  101. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  102. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  103. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  104. package/src/utils/fapiAutoDetect.ts +12 -39
  105. package/src/utils/ssoReturn.ts +2 -2
@@ -336,8 +336,15 @@ export class AuthManager {
336
336
  * Get default storage based on environment.
337
337
  */
338
338
  getDefaultStorage() {
339
- if (typeof window !== 'undefined' && window.localStorage) {
340
- return new LocalStorageAdapter();
339
+ try {
340
+ if (typeof window !== 'undefined' && window.localStorage) {
341
+ return new LocalStorageAdapter();
342
+ }
343
+ }
344
+ catch {
345
+ // Accessing window.localStorage can throw in opaque-origin/sandboxed
346
+ // browser contexts or when storage is disabled. Fall back to memory so
347
+ // AuthManager construction remains safe during provider render.
341
348
  }
342
349
  return new MemoryStorage();
343
350
  }
@@ -348,13 +348,13 @@ export class HttpService {
348
348
  // use fetch on every platform.
349
349
  const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
350
350
  const response = useXhrForUpload
351
- ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout)
351
+ ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, this.shouldSendCredentials(fullUrl))
352
352
  : await fetch(fullUrl, {
353
353
  method,
354
354
  headers,
355
355
  body: bodyValue,
356
356
  signal: controller.signal,
357
- credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
357
+ credentials: this.getCredentialsMode(fullUrl),
358
358
  });
359
359
  if (timeoutId)
360
360
  clearTimeout(timeoutId);
@@ -380,7 +380,7 @@ export class HttpService {
380
380
  const errBody = await clonedResponse.json();
381
381
  if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
382
382
  this.tokenStore.clearCsrfToken();
383
- return this.request({ ...config, _isCsrfRetry: true, retry: false });
383
+ return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
384
384
  }
385
385
  }
386
386
  catch {
@@ -414,7 +414,10 @@ export class HttpService {
414
414
  // Handle different response types (optimized - read response once)
415
415
  const contentType = response.headers.get('content-type');
416
416
  let responseData;
417
- if (contentType && contentType.includes('application/json')) {
417
+ if (config.responseType === 'blob') {
418
+ responseData = await response.blob();
419
+ }
420
+ else if (contentType && contentType.includes('application/json')) {
418
421
  // Use response.json() directly for better performance
419
422
  try {
420
423
  responseData = await response.json();
@@ -525,13 +528,14 @@ export class HttpService {
525
528
  * (status checks, 401/403 retries, JSON/blob/text parsing) is identical
526
529
  * to the fetch path.
527
530
  */
528
- uploadViaXHR(url, method, headers, body, abortSignal, timeout) {
531
+ uploadViaXHR(url, method, headers, body, abortSignal, timeout, withCredentials) {
529
532
  return new Promise((resolve, reject) => {
530
533
  const xhr = new XMLHttpRequest();
531
534
  xhr.open(method, url, true);
532
- // withCredentials mirrors fetch's `credentials: 'include'` so the
533
- // session cookie and CSRF cookie continue to flow.
534
- xhr.withCredentials = true;
535
+ // Only send ambient cookies to the configured API origin. Absolute
536
+ // caller-supplied URLs can target arbitrary origins, so they must not
537
+ // receive credential-bearing requests by default.
538
+ xhr.withCredentials = withCredentials;
535
539
  // Forward headers but skip Content-Type — XHR sets the multipart
536
540
  // boundary automatically and overriding it breaks the upload.
537
541
  for (const [key, value] of Object.entries(headers)) {
@@ -684,6 +688,17 @@ export class HttpService {
684
688
  const queryString = searchParams.toString();
685
689
  return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
686
690
  }
691
+ getCredentialsMode(url) {
692
+ return this.shouldSendCredentials(url) ? 'include' : 'omit';
693
+ }
694
+ shouldSendCredentials(url) {
695
+ try {
696
+ return new URL(url).origin === new URL(this.baseURL).origin;
697
+ }
698
+ catch {
699
+ return false;
700
+ }
701
+ }
687
702
  /**
688
703
  * Fetch CSRF token from server (with deduplication)
689
704
  * Required for state-changing requests (POST, PUT, PATCH, DELETE)
@@ -718,8 +733,11 @@ export class HttpService {
718
733
  this.logger.debug('CSRF fetch response:', response.status, response.ok);
719
734
  if (response.ok) {
720
735
  const data = await response.json();
721
- this.logger.debug('CSRF response data:', data);
722
736
  const token = data.csrfToken || null;
737
+ this.logger.debug('CSRF response data:', {
738
+ hasCsrfToken: typeof token === 'string' && token.length > 0,
739
+ csrfTokenLength: token?.length,
740
+ });
723
741
  this.tokenStore.setCsrfToken(token);
724
742
  this.logger.debug('CSRF token fetched');
725
743
  return token;
@@ -229,8 +229,9 @@ export class OxyServicesBase {
229
229
  }
230
230
  try {
231
231
  const decoded = jwtDecode(accessToken);
232
- this._cachedUserId = decoded.userId || decoded.id || null;
233
- return this._cachedUserId;
232
+ const userId = decoded.userId || decoded.id || null;
233
+ this._cachedUserId = userId;
234
+ return userId;
234
235
  }
235
236
  catch {
236
237
  this._cachedUserId = null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Canonical JSON (RFC 8785 / JCS-style) serialization.
3
+ *
4
+ * `canonicalize(value)` produces a deterministic string for any JSON-compatible
5
+ * value so that a client which SIGNS a record and a server which VERIFIES it
6
+ * agree byte-for-byte on the signing input — regardless of the order in which
7
+ * object keys happen to be written, how the value was deserialized, or which
8
+ * runtime built it.
9
+ *
10
+ * This is the load-bearing primitive for the self-sovereign identity layer's
11
+ * signed records (`SignatureService.signRecord` + the API's record-verify path):
12
+ * both sides import THIS function from `@oxyhq/core`, so cross-implementation
13
+ * number/string formatting differences cannot cause a verify mismatch.
14
+ *
15
+ * Rules (the JSON Canonicalization Scheme subset we need):
16
+ * - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
17
+ * `Array.prototype.sort` order) and serialized recursively. Properties whose
18
+ * value is `undefined`, a function, or a symbol are OMITTED (matching
19
+ * `JSON.stringify` object semantics).
20
+ * - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
21
+ * serialize to `null` (matching `JSON.stringify` array semantics).
22
+ * - `null`, booleans, strings, and finite numbers serialize via the standard
23
+ * JSON representation.
24
+ * - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
25
+ * result first, then serialized — so a `Date` and its ISO-string equivalent
26
+ * canonicalize identically (the wire always carries the string form).
27
+ * - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
28
+ * JSON data model and throw, rather than silently producing `null`.
29
+ *
30
+ * Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
31
+ * expo. Safe in the dual CJS + ESM build.
32
+ */
33
+ function hasToJSON(value) {
34
+ return typeof value.toJSON === 'function';
35
+ }
36
+ /**
37
+ * Serialize a single value into its canonical JSON fragment. Recursive; called
38
+ * on each nested member. Object keys are sorted at every level.
39
+ */
40
+ function serialize(value) {
41
+ if (value === null) {
42
+ return 'null';
43
+ }
44
+ const valueType = typeof value;
45
+ if (valueType === 'number') {
46
+ if (!Number.isFinite(value)) {
47
+ throw new Error('canonicalize: non-finite numbers cannot be serialized');
48
+ }
49
+ return JSON.stringify(value);
50
+ }
51
+ if (valueType === 'string' || valueType === 'boolean') {
52
+ return JSON.stringify(value);
53
+ }
54
+ if (valueType === 'bigint') {
55
+ throw new Error('canonicalize: bigint values cannot be serialized');
56
+ }
57
+ if (Array.isArray(value)) {
58
+ const items = value.map((item) => {
59
+ const itemType = typeof item;
60
+ // JSON array semantics: undefined / function / symbol become null so the
61
+ // element positions (and therefore the array length) are preserved.
62
+ if (item === undefined || itemType === 'function' || itemType === 'symbol') {
63
+ return 'null';
64
+ }
65
+ return serialize(item);
66
+ });
67
+ return `[${items.join(',')}]`;
68
+ }
69
+ if (valueType === 'object') {
70
+ const obj = value;
71
+ if (hasToJSON(obj)) {
72
+ return serialize(obj.toJSON());
73
+ }
74
+ const record = obj;
75
+ const parts = [];
76
+ for (const key of Object.keys(record).sort()) {
77
+ const member = record[key];
78
+ const memberType = typeof member;
79
+ // JSON object semantics: properties with undefined / function / symbol
80
+ // values are omitted entirely.
81
+ if (member === undefined || memberType === 'function' || memberType === 'symbol') {
82
+ continue;
83
+ }
84
+ parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
85
+ }
86
+ return `{${parts.join(',')}}`;
87
+ }
88
+ // undefined / function / symbol at the top level have no JSON representation.
89
+ throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
90
+ }
91
+ /**
92
+ * Produce the canonical JSON string for `value`.
93
+ *
94
+ * Deterministic: two structurally-equal values yield identical strings even if
95
+ * their object keys were written in different orders. Use this — never an
96
+ * ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
97
+ * signed records, so client signing and server verification cannot drift.
98
+ *
99
+ * @throws if `value` (or any nested member used as the top-level/primitive)
100
+ * contains a non-finite number or a `bigint`, which have no JSON form.
101
+ */
102
+ export function canonicalize(value) {
103
+ return serialize(value);
104
+ }
@@ -57,9 +57,9 @@ const STORAGE_KEYS = {
57
57
  /**
58
58
  * iOS Keychain Access Group for sharing identities across Oxy apps
59
59
  * All Oxy apps must have this access group enabled in their entitlements
60
- * Format: [Team ID].com.oxy.shared or group.com.oxy.shared
60
+ * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
61
61
  */
62
- const IOS_KEYCHAIN_GROUP = 'group.com.oxy.shared';
62
+ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
63
63
  /**
64
64
  * Android Account Manager type for shared authentication
65
65
  * Used with sharedUserId to share sessions across apps
@@ -691,11 +691,25 @@ export class KeyManager {
691
691
  throw new IdentityPersistError('Stored identity failed crypto self-test', error);
692
692
  }
693
693
  // Step 3: The new primary is durable and functional. NOW it is safe to
694
- // refresh the backup to the new key. If this final backup write fails the
695
- // user still has a fully working primary, and the backup still holds the
696
- // PREVIOUS good identity so we log and continue rather than failing the
697
- // whole operation (failing here would be strictly worse: a working
698
- // primary would be reported as an error to the caller).
694
+ // refresh the backup to the new key. This is part of the successful write
695
+ // contract: returning success while the backup still belongs to the
696
+ // previous identity would allow a later restore with an absent primary to
697
+ // silently switch the device back to the previous account. Snapshot the
698
+ // backup first so a partial backup refresh can be rolled back along with
699
+ // the primary before surfacing the failure.
700
+ let priorBackupPrivate;
701
+ let priorBackupPublic;
702
+ let priorBackupTimestamp;
703
+ try {
704
+ priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
705
+ priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
706
+ priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
707
+ }
708
+ catch (error) {
709
+ logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
710
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
711
+ throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
712
+ }
699
713
  try {
700
714
  await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
701
715
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
@@ -704,12 +718,57 @@ export class KeyManager {
704
718
  await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
705
719
  }
706
720
  catch (error) {
707
- logger.warn('Primary identity persisted successfully but refreshing the backup failed; primary is usable, backup may be stale', { component: 'KeyManager' }, error);
721
+ logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
722
+ await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
723
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
724
+ throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
708
725
  }
709
726
  // Update cache only after we are certain the identity is durable.
710
727
  KeyManager.cachedPublicKey = canonicalPublic;
711
728
  KeyManager.cachedHasIdentity = true;
712
729
  }
730
+ /**
731
+ * Restore the backup slot to a previously-snapshotted state. Best-effort so
732
+ * the original persistence error remains the one surfaced to the caller.
733
+ *
734
+ * @internal
735
+ */
736
+ static async _rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
737
+ try {
738
+ if (priorBackupPrivate) {
739
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
740
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
741
+ });
742
+ }
743
+ else {
744
+ try {
745
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
746
+ }
747
+ catch { /* best effort */ }
748
+ }
749
+ if (priorBackupPublic) {
750
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
751
+ }
752
+ else {
753
+ try {
754
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
755
+ }
756
+ catch { /* best effort */ }
757
+ }
758
+ if (priorBackupTimestamp) {
759
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
760
+ }
761
+ else {
762
+ try {
763
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
764
+ }
765
+ catch { /* best effort */ }
766
+ }
767
+ }
768
+ catch (rollbackError) {
769
+ logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
770
+ }
771
+ }
713
772
  /**
714
773
  * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
715
774
  * or delete it entirely if there was no prior identity. Best-effort: every
@@ -7,11 +7,24 @@
7
7
  import _cjs_elliptic from 'elliptic';
8
8
  const { ec: EC } = _cjs_elliptic;
9
9
  import { KeyManager } from './keyManager.js';
10
+ import { canonicalize } from './canonicalJson.js';
10
11
  import { isReactNative, isNodeJS } from '../utils/platform.js';
11
12
  import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto.js';
12
13
  import { logger } from '../utils/loggerUtils.js';
13
14
  import { isDev } from '../shared/utils/debugUtils.js';
14
15
  const ec = new EC('secp256k1');
16
+ /**
17
+ * Compute the canonical signing input for a signed-record envelope.
18
+ *
19
+ * This is the single definition of "what the signature covers": the canonical
20
+ * JSON of `{version, type, subject, issuer, record, issuedAt}`. `@oxyhq/core`
21
+ * (client signing) and `@oxyhq/api` (server verification) both call this, so a
22
+ * record signed by a client and verified by the server cannot drift.
23
+ */
24
+ export function signedRecordSigningInput(fields) {
25
+ const { version, type, subject, issuer, record, issuedAt } = fields;
26
+ return canonicalize({ version, type, subject, issuer, record, issuedAt });
27
+ }
15
28
  /**
16
29
  * Compute SHA-256 hash of a string
17
30
  */
@@ -199,6 +212,36 @@ export class SignatureService {
199
212
  timestamp,
200
213
  };
201
214
  }
215
+ /**
216
+ * Create a signed authentication challenge response using the SHARED identity
217
+ * key (the cross-app `group.so.oxy.shared` keychain key), not the primary
218
+ * device key.
219
+ *
220
+ * Mirrors {@link signChallenge} exactly — same message format
221
+ * (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
222
+ * path is unchanged — but sources the shared public/private key from
223
+ * `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
224
+ * same-device shared-keychain SSO (Mechanism A): a sibling native app proves
225
+ * control of the shared identity to mint its own session.
226
+ *
227
+ * Throws if no shared identity exists (native-only; the shared keychain is
228
+ * unavailable on web).
229
+ */
230
+ static async signChallengeWithSharedKey(challenge) {
231
+ const publicKey = await KeyManager.getSharedPublicKey();
232
+ const privateKey = await KeyManager.getSharedPrivateKey();
233
+ if (!publicKey || !privateKey) {
234
+ throw new Error('No shared identity found. Cannot sign with the shared key.');
235
+ }
236
+ const timestamp = Date.now();
237
+ const message = `auth:${publicKey}:${challenge}:${timestamp}`;
238
+ const signature = await SignatureService.signWithKey(message, privateKey);
239
+ return {
240
+ challenge: signature,
241
+ publicKey,
242
+ timestamp,
243
+ };
244
+ }
202
245
  /**
203
246
  * Verify a challenge response
204
247
  */
@@ -253,5 +296,64 @@ export class SignatureService {
253
296
  timestamp,
254
297
  };
255
298
  }
299
+ /**
300
+ * Build a signed-record envelope for a self-issued identity/profile record.
301
+ *
302
+ * The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
303
+ * The signature covers the canonical JSON of every field EXCEPT `publicKey`
304
+ * and `signature` (see {@link signedRecordSigningInput}); `alg` is
305
+ * `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
306
+ * DER-encoded), the same scheme this service uses everywhere else.
307
+ *
308
+ * Requires a stored identity (native secure storage); throws if none exists.
309
+ *
310
+ * @param type - The record category (`'identity'` or `'profile'`).
311
+ * @param subject - The subject DID the record is about (also the issuer).
312
+ * @param record - The arbitrary record payload to attest to.
313
+ */
314
+ static async signRecord(type, subject, record) {
315
+ const publicKey = await KeyManager.getPublicKey();
316
+ if (!publicKey) {
317
+ throw new Error('No identity found. Please create or import an identity first.');
318
+ }
319
+ const version = 1;
320
+ const issuer = subject;
321
+ const issuedAt = Date.now();
322
+ const signingInput = signedRecordSigningInput({
323
+ version,
324
+ type,
325
+ subject,
326
+ issuer,
327
+ record,
328
+ issuedAt,
329
+ });
330
+ const signature = await SignatureService.sign(signingInput);
331
+ return {
332
+ version,
333
+ type,
334
+ subject,
335
+ issuer,
336
+ record,
337
+ issuedAt,
338
+ publicKey,
339
+ alg: 'ES256K-DER-SHA256',
340
+ signature,
341
+ };
342
+ }
343
+ /**
344
+ * Verify a signed-record envelope: recompute the canonical signing input from
345
+ * the envelope's own fields and check the signature against the envelope's
346
+ * `publicKey`.
347
+ *
348
+ * Note: this confirms the signature is internally consistent with the
349
+ * embedded `publicKey`. It does NOT establish that `publicKey` is an
350
+ * authorized verification method for `subject` — that authorization check is
351
+ * the server's responsibility (it asserts the key is a current verification
352
+ * method on the subject's DID).
353
+ */
354
+ static async verifyRecord(envelope) {
355
+ const signingInput = signedRecordSigningInput(envelope);
356
+ return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
357
+ }
256
358
  }
257
359
  export default SignatureService;
@@ -129,6 +129,15 @@
129
129
  "title": "Reputation = Trust & Growth",
130
130
  "body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
131
131
  },
132
+ "name": {
133
+ "title": "What's your name?",
134
+ "body": "Add your name so people know who you are.",
135
+ "firstLabel": "First name",
136
+ "firstPlaceholder": "Your first name",
137
+ "lastLabel": "Last name",
138
+ "lastPlaceholder": "Your last name",
139
+ "saveFailed": "Could not save your name"
140
+ },
132
141
  "avatar": {
133
142
  "title": "Make It Yours",
134
143
  "body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
@@ -849,6 +849,15 @@
849
849
  "title": "Reputación = Confianza y crecimiento",
850
850
  "body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
851
851
  },
852
+ "name": {
853
+ "title": "¿Cuál es tu nombre?",
854
+ "body": "Añade tu nombre para que la gente sepa quién eres.",
855
+ "firstLabel": "Nombre",
856
+ "firstPlaceholder": "Tu nombre",
857
+ "lastLabel": "Apellidos",
858
+ "lastPlaceholder": "Tus apellidos",
859
+ "saveFailed": "No se pudo guardar tu nombre"
860
+ },
852
861
  "avatar": {
853
862
  "title": "Hazlo tuyo",
854
863
  "body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
@@ -129,6 +129,15 @@
129
129
  "title": "Reputation = Trust & Growth",
130
130
  "body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
131
131
  },
132
+ "name": {
133
+ "title": "What's your name?",
134
+ "body": "Add your name so people know who you are.",
135
+ "firstLabel": "First name",
136
+ "firstPlaceholder": "Your first name",
137
+ "lastLabel": "Last name",
138
+ "lastPlaceholder": "Your last name",
139
+ "saveFailed": "Could not save your name"
140
+ },
132
141
  "avatar": {
133
142
  "title": "Make It Yours",
134
143
  "body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
@@ -849,6 +849,15 @@
849
849
  "title": "Reputación = Confianza y crecimiento",
850
850
  "body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
851
851
  },
852
+ "name": {
853
+ "title": "¿Cuál es tu nombre?",
854
+ "body": "Añade tu nombre para que la gente sepa quién eres.",
855
+ "firstLabel": "Nombre",
856
+ "firstPlaceholder": "Tu nombre",
857
+ "lastLabel": "Apellidos",
858
+ "lastPlaceholder": "Tus apellidos",
859
+ "saveFailed": "No se pudo guardar tu nombre"
860
+ },
852
861
  "avatar": {
853
862
  "title": "Hazlo tuyo",
854
863
  "body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
package/dist/esm/index.js CHANGED
@@ -36,6 +36,13 @@ export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
36
36
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity.js';
37
37
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle.js';
38
38
  // ---------------------------------------------------------------------------
39
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
40
+ // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
41
+ // AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
42
+ // ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
43
+ // ---------------------------------------------------------------------------
44
+ export { buildUserDid } from './mixins/OxyServices.identity.js';
45
+ // ---------------------------------------------------------------------------
39
46
  // Auth helpers (token refresh, error normalisation, retry policies)
40
47
  // ---------------------------------------------------------------------------
41
48
  export { SessionSyncRequiredError, AuthenticationFailedError, ensureValidToken, isAuthenticationError, withAuthErrorHandling, authenticatedApiCall, } from './utils/authHelpers.js';
@@ -47,7 +54,8 @@ export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual, } from '.
47
54
  // Crypto / identity
48
55
  // ---------------------------------------------------------------------------
49
56
  export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto/keyManager.js';
50
- export { SignatureService } from './crypto/signatureService.js';
57
+ export { SignatureService, signedRecordSigningInput } from './crypto/signatureService.js';
58
+ export { canonicalize } from './crypto/canonicalJson.js';
51
59
  export { RecoveryPhraseService } from './crypto/recoveryPhrase.js';
52
60
  // ---------------------------------------------------------------------------
53
61
  // Devices
@@ -104,7 +112,7 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
104
112
  // ---------------------------------------------------------------------------
105
113
  // Cross-domain SSO infrastructure
106
114
  // ---------------------------------------------------------------------------
107
- export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect.js';
115
+ export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect.js';
108
116
  // Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
109
117
  export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl.js';
110
118
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
@@ -1,3 +1,4 @@
1
+ import { isReactNative } from '../utils/platform.js';
1
2
  export function OxyServicesAssetsMixin(Base) {
2
3
  return class extends Base {
3
4
  constructor(...args) {
@@ -40,8 +41,8 @@ export function OxyServicesAssetsMixin(Base) {
40
41
  *
41
42
  * For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
42
43
  */
43
- getFileDownloadUrl(fileId, variant, expiresIn) {
44
- const token = this.getClient().getAccessToken();
44
+ getFileDownloadUrl(fileId, variant, expiresIn, options = {}) {
45
+ const token = options.omitToken ? undefined : this.getClient().getAccessToken();
45
46
  // Public case: no auth token and no expiry requested → clean CDN URL.
46
47
  // CloudFront serves the public media origin under `${cloudURL}/<id>`.
47
48
  if (!token && !expiresIn) {
@@ -189,10 +190,32 @@ export function OxyServicesAssetsMixin(Base) {
189
190
  formData.append('file', file, fileName);
190
191
  }
191
192
  else if ('uri' in file && typeof file.uri === 'string') {
192
- // React Native file descriptor — RN's FormData handles {uri, type, name} natively.
193
- // It reads the file from disk during the multipart request — no in-JS Blob
194
- // conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
195
- formData.append('file', file, fileName);
193
+ const descriptor = file;
194
+ if (isReactNative()) {
195
+ // React Native file descriptor RN's FormData handles {uri, type, name} natively.
196
+ // It reads the file from disk during the multipart request — no in-JS Blob
197
+ // conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
198
+ formData.append('file', descriptor, fileName);
199
+ }
200
+ else {
201
+ // Web (browser/Node): the browser's FormData cannot read bytes from a plain
202
+ // { uri } object — it would serialize "[object Object]" and the server would
203
+ // store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
204
+ // resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
205
+ const res = await fetch(descriptor.uri);
206
+ if (!res.ok) {
207
+ throw new Error(`Failed to read file from uri (status ${res.status})`);
208
+ }
209
+ const fetched = await res.blob();
210
+ // Preserve the descriptor's declared MIME type when the fetched blob has none.
211
+ const blob = fetched.type === '' && descriptor.type
212
+ ? new Blob([fetched], { type: descriptor.type })
213
+ : fetched;
214
+ if (blob.size === 0) {
215
+ throw new Error('Cannot upload an empty file');
216
+ }
217
+ formData.append('file', blob, fileName);
218
+ }
196
219
  }
197
220
  else {
198
221
  throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
@@ -415,7 +438,9 @@ export function OxyServicesAssetsMixin(Base) {
415
438
  return urlRes?.url || null;
416
439
  }
417
440
  async fetchAssetContent(url, type) {
418
- const response = await fetch(url, { credentials: 'include' });
441
+ const response = await fetch(url, {
442
+ credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
443
+ });
419
444
  if (!response?.ok) {
420
445
  throw new Error(`Failed to fetch asset content (status ${response?.status})`);
421
446
  }
@@ -423,3 +448,16 @@ export function OxyServicesAssetsMixin(Base) {
423
448
  }
424
449
  };
425
450
  }
451
+ /**
452
+ * Only send ambient credentials (cookies) when the asset URL is same-origin with
453
+ * the configured API base. Caller-supplied cross-origin asset URLs must not leak
454
+ * the user's cookies to arbitrary hosts.
455
+ */
456
+ function shouldSendAssetCredentials(url, baseURL) {
457
+ try {
458
+ return new URL(url).origin === new URL(baseURL).origin;
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }