@oxyhq/core 12.4.1 → 12.5.1

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.
@@ -4,14 +4,26 @@
4
4
  * Ensures Buffer and crypto.getRandomValues are available
5
5
  * across all platforms (Node.js, Browser, React Native).
6
6
  *
7
- * - Browser/Node.js: Uses native crypto
8
- * - React Native: Uses expo-crypto (statically imported via the
9
- * per-platform `platform/crypto` module in `@oxyhq/protocol` — see that
10
- * file's doc-comment for how platform routing works).
7
+ * Guard order when installing a `getRandomValues` shim (see bottom of file and
8
+ * {@link cryptoPolyfill}):
9
+ *
10
+ * 1. A REAL `globalThis.crypto.getRandomValues` used as-is (browser, Node
11
+ * >= 20, modern Hermes). The shim below is only installed when the host is
12
+ * missing it, so this branch is the install-time gate.
13
+ * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
+ * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
+ * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
+ * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
+ * per-platform `platform/crypto` module in `@oxyhq/protocol`).
18
+ *
19
+ * Historically step (2) delegated to `@oxyhq/protocol`'s RN-only
20
+ * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
21
+ * WebCrypto crashed here instead of getting randomness. It is now a proper
22
+ * Node-backed implementation.
11
23
  */
12
24
  import _cjs_buffer from 'buffer';
13
25
  const { Buffer } = _cjs_buffer;
14
- import { getRandomBytesRN } from '@oxyhq/protocol';
26
+ import { getRandomBytesRN, isNodeJS } from '@oxyhq/protocol';
15
27
  const getGlobalObject = () => {
16
28
  if (typeof globalThis !== 'undefined')
17
29
  return globalThis;
@@ -29,23 +41,79 @@ if (!globalObject.Buffer) {
29
41
  globalObject.Buffer = Buffer;
30
42
  }
31
43
  /**
32
- * Synchronous random-bytes shim. On RN, this delegates to
33
- * `expo-crypto.getRandomBytes` (statically imported by the RN variant of
34
- * `@oxyhq/protocol`'s `platform/crypto`, so available without any async
35
- * warm-up). On Node /
36
- * browser, this throws — but is never called there because both platforms
37
- * already provide `globalThis.crypto.getRandomValues` natively.
44
+ * Lazily-resolved `node:crypto` module, cached after the first attempt.
45
+ * `undefined` = not tried yet; `null` = tried and unavailable (non-Node host).
46
+ */
47
+ let cachedNodeCrypto;
48
+ /**
49
+ * Synchronously load `node:crypto` on a Node runtime, or `null` elsewhere.
50
+ *
51
+ * Uses a guarded, Node-only `require`. Every runtime that actually reaches this
52
+ * branch has a working CommonJS `require`: `@oxyhq/core` publishes no
53
+ * `"type": "module"`, so Node loads it as CommonJS and the `require` free
54
+ * variable is present. Browsers never reach here (they own `globalThis.crypto`,
55
+ * so this polyfill is never installed) and React Native takes the
56
+ * `getRandomBytesRN` branch — so the `node:crypto` reference is dead code in
57
+ * those bundles, and Expo's Metro resolver shims `node:*` builtins, keeping
58
+ * web/native bundles green.
59
+ */
60
+ function loadNodeCryptoSync() {
61
+ if (cachedNodeCrypto !== undefined) {
62
+ return cachedNodeCrypto;
63
+ }
64
+ if (typeof require !== 'function') {
65
+ cachedNodeCrypto = null;
66
+ return cachedNodeCrypto;
67
+ }
68
+ try {
69
+ cachedNodeCrypto = require('node:crypto');
70
+ }
71
+ catch {
72
+ // No Node crypto (unexpected on a real Node host) — degrade to the next
73
+ // mechanism rather than crash.
74
+ cachedNodeCrypto = null;
75
+ }
76
+ return cachedNodeCrypto;
77
+ }
78
+ /**
79
+ * Fill `array` with cryptographically-secure random bytes from `node:crypto`.
80
+ * Prefers `webcrypto.getRandomValues`; falls back to `randomFillSync`. Returns
81
+ * `false` when Node crypto is unavailable so the caller can try the next
82
+ * mechanism.
38
83
  */
39
- function getRandomBytesSync(byteCount) {
40
- // `getRandomBytesRN` throws on non-RN platforms. That's fine: this
41
- // function is only ever called as a fallback when the native
42
- // `globalThis.crypto.getRandomValues` is missing, which on a normal
43
- // Node/browser host never happens.
44
- return getRandomBytesRN(byteCount);
84
+ function fillFromNodeCrypto(array) {
85
+ const nodeCrypto = loadNodeCryptoSync();
86
+ if (!nodeCrypto) {
87
+ return false;
88
+ }
89
+ const webcrypto = nodeCrypto.webcrypto;
90
+ if (webcrypto && typeof webcrypto.getRandomValues === 'function') {
91
+ try {
92
+ webcrypto.getRandomValues(array);
93
+ return true;
94
+ }
95
+ catch {
96
+ // `webcrypto.getRandomValues` rejects non-integer views (Float*Array,
97
+ // DataView); fall through to `randomFillSync`, which accepts any view.
98
+ }
99
+ }
100
+ if (typeof nodeCrypto.randomFillSync === 'function') {
101
+ nodeCrypto.randomFillSync(array);
102
+ return true;
103
+ }
104
+ return false;
45
105
  }
46
106
  const cryptoPolyfill = {
47
107
  getRandomValues(array) {
48
- const bytes = getRandomBytesSync(array.byteLength);
108
+ // Node: back the CSPRNG with `node:crypto`. This is the path that matters on
109
+ // Node runtimes shipping WITHOUT a global WebCrypto — where delegating to
110
+ // the RN-only expo-crypto stub would throw.
111
+ if (isNodeJS() && fillFromNodeCrypto(array)) {
112
+ return array;
113
+ }
114
+ // React Native (and any non-Node host without WebCrypto): synchronous
115
+ // expo-crypto via @oxyhq/protocol's RN `platform/crypto` variant.
116
+ const bytes = getRandomBytesRN(array.byteLength);
49
117
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
50
118
  uint8View.set(bytes);
51
119
  return array;
@@ -437,6 +437,17 @@ export function OxyServicesUserMixin(Base) {
437
437
  async updateUserPreferences(preferences) {
438
438
  return this.updateProfile({ userPreferences: preferences });
439
439
  }
440
+ /**
441
+ * Update the authenticated user's portable theme preference (light/dark/
442
+ * system + Bloom color-preset key). Persisted on the User document via the
443
+ * SAME `PUT /users/me` settings path as the other preferences — same cache
444
+ * invalidation — so the next cold boot serves it on the self/session payload
445
+ * with no extra network call. The full object is written (both `mode` and
446
+ * `colorPreset` are required by the API).
447
+ */
448
+ async updateThemePreference(themePreference) {
449
+ return this.updateProfile({ themePreference });
450
+ }
440
451
  /**
441
452
  * Request account verification
442
453
  */
@@ -372,7 +372,7 @@ function buildRequestOptions(target, pinnedIp, pinnedFamily, method, headers, si
372
372
  };
373
373
  }
374
374
  /** Perform a single upstream request (no auto-redirect). */
375
- function fetchOnce(options, isHttps, headersTimeoutMs) {
375
+ function fetchOnce(options, isHttps, headersTimeoutMs, body) {
376
376
  return new Promise((resolve, reject) => {
377
377
  const transport = isHttps ? https : http;
378
378
  const req = transport.request(options, (res) => resolve(res));
@@ -380,7 +380,7 @@ function fetchOnce(options, isHttps, headersTimeoutMs) {
380
380
  req.destroy(new UpstreamError('upstream headers timeout'));
381
381
  });
382
382
  req.on('error', (err) => reject(err));
383
- req.end();
383
+ req.end(body);
384
384
  });
385
385
  }
386
386
  /**
@@ -395,7 +395,7 @@ function fetchOnce(options, isHttps, headersTimeoutMs) {
395
395
  * @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
396
396
  */
397
397
  export async function safeFetch(rawUrl, options = {}) {
398
- const { method = 'GET', headers: callerHeaders, maxRedirects = MAX_REDIRECTS, headersTimeoutMs = UPSTREAM_HEADERS_TIMEOUT_MS, signal, } = options;
398
+ const { method = 'GET', headers: callerHeaders, body, maxRedirects = MAX_REDIRECTS, headersTimeoutMs = UPSTREAM_HEADERS_TIMEOUT_MS, signal, } = options;
399
399
  // Normalize a case-insensitive header map and ensure a User-Agent default.
400
400
  const baseHeaders = {};
401
401
  if (callerHeaders) {
@@ -418,7 +418,7 @@ export async function safeFetch(rawUrl, options = {}) {
418
418
  }
419
419
  const target = new URL(currentUrl);
420
420
  const requestOptions = buildRequestOptions(target, guard.ip, guard.family, method, baseHeaders, signal);
421
- const response = await fetchOnce(requestOptions, target.protocol === 'https:', headersTimeoutMs);
421
+ const response = await fetchOnce(requestOptions, target.protocol === 'https:', headersTimeoutMs, body);
422
422
  const status = response.statusCode ?? 0;
423
423
  if (REDIRECT_STATUS_CODES.has(status)) {
424
424
  const location = response.headers.location;