@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,10 +4,22 @@
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 { Buffer } from 'buffer';
13
25
  export { Buffer };
@@ -2,7 +2,7 @@
2
2
  * User Management Methods Mixin
3
3
  */
4
4
  import type { User, Notification, NotificationPreferences, UserPreferences, SearchProfilesResponse, PrivacySettings } from '../models/interfaces';
5
- import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem } from '@oxyhq/contracts';
5
+ import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem, ThemePreference } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { type PaginationParams } from '../utils/apiUtils';
8
8
  /**
@@ -292,6 +292,15 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
292
292
  * `PUT /users/me` — same cache-invalidation behaviour as `updateProfile`.
293
293
  */
294
294
  updateUserPreferences(preferences: Partial<UserPreferences>): Promise<User>;
295
+ /**
296
+ * Update the authenticated user's portable theme preference (light/dark/
297
+ * system + Bloom color-preset key). Persisted on the User document via the
298
+ * SAME `PUT /users/me` settings path as the other preferences — same cache
299
+ * invalidation — so the next cold boot serves it on the self/session payload
300
+ * with no extra network call. The full object is written (both `mode` and
301
+ * `colorPreset` are required by the API).
302
+ */
303
+ updateThemePreference(themePreference: ThemePreference): Promise<User>;
295
304
  /**
296
305
  * Request account verification
297
306
  */
@@ -1,4 +1,4 @@
1
- import type { OrganizationCategory, UserNameResponse } from '@oxyhq/contracts';
1
+ import type { OrganizationCategory, UserNameResponse, UserRelationship, ThemePreference } from '@oxyhq/contracts';
2
2
  export interface OxyConfig {
3
3
  baseURL: string;
4
4
  cloudURL?: string;
@@ -147,6 +147,20 @@ export interface User {
147
147
  languages?: string[];
148
148
  notificationPreferences?: NotificationPreferences;
149
149
  userPreferences?: UserPreferences;
150
+ /**
151
+ * Portable theme preference (mode + Bloom color-preset key). Rides the
152
+ * self/session payload so it is present on the current-user DTO at cold boot
153
+ * (`useAuth().user.themePreference`) with no extra fetch. Absent until the
154
+ * user sets it. Updated via `updateThemePreference` / `updateProfile`.
155
+ */
156
+ themePreference?: ThemePreference;
157
+ /**
158
+ * The authenticated viewer's relationship to THIS profile. Populated ONLY on
159
+ * single-profile fetches (`getProfileByUsername` / `getUserById`) when the
160
+ * request is authenticated; absent for anonymous requests, self-views, and
161
+ * bulk fetches — so `undefined` means "unknown", not "not following".
162
+ */
163
+ relationship?: UserRelationship;
150
164
  [key: string]: unknown;
151
165
  }
152
166
  /**
@@ -91,6 +91,8 @@ export interface SafeFetchOptions {
91
91
  method?: string;
92
92
  /** Extra request headers. A `User-Agent` is added if none is provided. */
93
93
  headers?: Record<string, string>;
94
+ /** Request body for POST/PUT/PATCH. */
95
+ body?: string | Buffer;
94
96
  /**
95
97
  * Maximum number of redirects to follow (each re-validated). Defaults to
96
98
  * {@link MAX_REDIRECTS}. Set to `0` to disallow redirects.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.4.1",
3
+ "version": "12.5.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -111,7 +111,7 @@
111
111
  "dependencies": {
112
112
  "@noble/ciphers": "^1.3.0",
113
113
  "@noble/hashes": "^1.8.0",
114
- "@oxyhq/contracts": "^0.16.0",
114
+ "@oxyhq/contracts": "^0.17.0",
115
115
  "@oxyhq/protocol": "^0.1.5",
116
116
  "bip39": "^3.1.0",
117
117
  "buffer": "^6.0.3",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Crypto polyfill — `getRandomValues` install + platform routing.
3
+ *
4
+ * Regression coverage for the latent Node crash: when a Node runtime ships
5
+ * WITHOUT a global WebCrypto (Node 18 script entrypoints, some embedded hosts),
6
+ * the installed `getRandomValues` shim must be backed by `node:crypto` and MUST
7
+ * NOT fall through to `@oxyhq/protocol`'s RN-only `getRandomBytesRN` stub (which
8
+ * throws `Tried to load 'expo-crypto...' outside React Native`).
9
+ */
10
+
11
+ // Controllable stand-ins for `@oxyhq/protocol`'s platform predicates. Names are
12
+ // `mock`-prefixed so the (hoisted) `jest.mock` factory may reference them.
13
+ const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
14
+ const mockState = { isNodeJS: true };
15
+
16
+ jest.mock('@oxyhq/protocol', () => ({
17
+ isNodeJS: () => mockState.isNodeJS,
18
+ getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
19
+ }));
20
+
21
+ type CryptoLike = {
22
+ getRandomValues: <T extends ArrayBufferView>(array: T) => T;
23
+ };
24
+
25
+ const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
26
+
27
+ /**
28
+ * Re-run the polyfill module with NO host `globalThis.crypto`, returning the
29
+ * `getRandomValues` shim it installs. Restores the real global afterwards so
30
+ * the manipulation never leaks into other tests.
31
+ */
32
+ function installShimWithoutHostCrypto(): CryptoLike {
33
+ Object.defineProperty(globalThis, 'crypto', {
34
+ value: undefined,
35
+ configurable: true,
36
+ writable: true,
37
+ });
38
+ try {
39
+ jest.isolateModules(() => {
40
+ require('../polyfill');
41
+ });
42
+ const installed = (globalThis as { crypto?: CryptoLike }).crypto;
43
+ if (!installed || typeof installed.getRandomValues !== 'function') {
44
+ throw new Error('polyfill did not install a getRandomValues shim');
45
+ }
46
+ return installed;
47
+ } finally {
48
+ Object.defineProperty(
49
+ globalThis,
50
+ 'crypto',
51
+ ORIGINAL_CRYPTO_DESCRIPTOR ?? { value: undefined, configurable: true, writable: true },
52
+ );
53
+ }
54
+ }
55
+
56
+ beforeEach(() => {
57
+ mockGetRandomBytesRN.mockReset();
58
+ mockState.isNodeJS = true;
59
+ });
60
+
61
+ describe('crypto polyfill getRandomValues', () => {
62
+ it('on Node without global WebCrypto, fills from node:crypto and never calls the RN stub', () => {
63
+ mockState.isNodeJS = true;
64
+ // If the shim ever fell through to the RN path on Node, this would throw —
65
+ // exactly the latent crash we are guarding against.
66
+ mockGetRandomBytesRN.mockImplementation(() => {
67
+ throw new Error('RN getRandomBytesRN must not be called on Node');
68
+ });
69
+
70
+ const shim = installShimWithoutHostCrypto();
71
+ const array = new Uint8Array(16);
72
+
73
+ expect(() => shim.getRandomValues(array)).not.toThrow();
74
+ // Backed by a real CSPRNG: all-zero output is cryptographically impossible.
75
+ expect(array.some((byte) => byte !== 0)).toBe(true);
76
+ expect(mockGetRandomBytesRN).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('on Node, routes a non-integer view (DataView) through randomFillSync', () => {
80
+ mockState.isNodeJS = true;
81
+ mockGetRandomBytesRN.mockImplementation(() => {
82
+ throw new Error('RN getRandomBytesRN must not be called on Node');
83
+ });
84
+
85
+ const shim = installShimWithoutHostCrypto();
86
+ const view = new DataView(new ArrayBuffer(16));
87
+
88
+ expect(() => shim.getRandomValues(view)).not.toThrow();
89
+ let anyNonZero = false;
90
+ for (let i = 0; i < view.byteLength; i += 1) {
91
+ if (view.getUint8(i) !== 0) anyNonZero = true;
92
+ }
93
+ expect(anyNonZero).toBe(true);
94
+ expect(mockGetRandomBytesRN).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it('on React Native, delegates to expo-crypto via getRandomBytesRN (path unchanged)', () => {
98
+ mockState.isNodeJS = false;
99
+ const rnBytes = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
100
+ mockGetRandomBytesRN.mockReturnValue(rnBytes);
101
+
102
+ const shim = installShimWithoutHostCrypto();
103
+ const array = new Uint8Array(8);
104
+ const result = shim.getRandomValues(array);
105
+
106
+ expect(mockGetRandomBytesRN).toHaveBeenCalledWith(8);
107
+ expect(Array.from(result)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
108
+ });
109
+ });
@@ -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
 
13
25
  import { Buffer } from 'buffer';
14
- import { getRandomBytesRN } from '@oxyhq/protocol';
26
+ import { getRandomBytesRN, isNodeJS } from '@oxyhq/protocol';
15
27
 
16
28
  const getGlobalObject = (): typeof globalThis => {
17
29
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -32,25 +44,89 @@ type CryptoLike = {
32
44
  getRandomValues: <T extends ArrayBufferView>(array: T) => T;
33
45
  };
34
46
 
47
+ /** Minimal structural shape of the parts of `node:crypto` this polyfill uses. */
48
+ interface NodeCryptoLike {
49
+ webcrypto?: {
50
+ getRandomValues?: <T extends ArrayBufferView>(array: T) => T;
51
+ };
52
+ randomFillSync?: <T extends ArrayBufferView>(buffer: T) => T;
53
+ }
54
+
55
+ /**
56
+ * Lazily-resolved `node:crypto` module, cached after the first attempt.
57
+ * `undefined` = not tried yet; `null` = tried and unavailable (non-Node host).
58
+ */
59
+ let cachedNodeCrypto: NodeCryptoLike | null | undefined;
60
+
61
+ /**
62
+ * Synchronously load `node:crypto` on a Node runtime, or `null` elsewhere.
63
+ *
64
+ * Uses a guarded, Node-only `require`. Every runtime that actually reaches this
65
+ * branch has a working CommonJS `require`: `@oxyhq/core` publishes no
66
+ * `"type": "module"`, so Node loads it as CommonJS and the `require` free
67
+ * variable is present. Browsers never reach here (they own `globalThis.crypto`,
68
+ * so this polyfill is never installed) and React Native takes the
69
+ * `getRandomBytesRN` branch — so the `node:crypto` reference is dead code in
70
+ * those bundles, and Expo's Metro resolver shims `node:*` builtins, keeping
71
+ * web/native bundles green.
72
+ */
73
+ function loadNodeCryptoSync(): NodeCryptoLike | null {
74
+ if (cachedNodeCrypto !== undefined) {
75
+ return cachedNodeCrypto;
76
+ }
77
+ if (typeof require !== 'function') {
78
+ cachedNodeCrypto = null;
79
+ return cachedNodeCrypto;
80
+ }
81
+ try {
82
+ cachedNodeCrypto = require('node:crypto') as NodeCryptoLike;
83
+ } catch {
84
+ // No Node crypto (unexpected on a real Node host) — degrade to the next
85
+ // mechanism rather than crash.
86
+ cachedNodeCrypto = null;
87
+ }
88
+ return cachedNodeCrypto;
89
+ }
90
+
35
91
  /**
36
- * Synchronous random-bytes shim. On RN, this delegates to
37
- * `expo-crypto.getRandomBytes` (statically imported by the RN variant of
38
- * `@oxyhq/protocol`'s `platform/crypto`, so available without any async
39
- * warm-up). On Node /
40
- * browser, this throws — but is never called there because both platforms
41
- * already provide `globalThis.crypto.getRandomValues` natively.
92
+ * Fill `array` with cryptographically-secure random bytes from `node:crypto`.
93
+ * Prefers `webcrypto.getRandomValues`; falls back to `randomFillSync`. Returns
94
+ * `false` when Node crypto is unavailable so the caller can try the next
95
+ * mechanism.
42
96
  */
43
- function getRandomBytesSync(byteCount: number): Uint8Array {
44
- // `getRandomBytesRN` throws on non-RN platforms. That's fine: this
45
- // function is only ever called as a fallback when the native
46
- // `globalThis.crypto.getRandomValues` is missing, which on a normal
47
- // Node/browser host never happens.
48
- return getRandomBytesRN(byteCount);
97
+ function fillFromNodeCrypto(array: ArrayBufferView): boolean {
98
+ const nodeCrypto = loadNodeCryptoSync();
99
+ if (!nodeCrypto) {
100
+ return false;
101
+ }
102
+ const webcrypto = nodeCrypto.webcrypto;
103
+ if (webcrypto && typeof webcrypto.getRandomValues === 'function') {
104
+ try {
105
+ webcrypto.getRandomValues(array);
106
+ return true;
107
+ } catch {
108
+ // `webcrypto.getRandomValues` rejects non-integer views (Float*Array,
109
+ // DataView); fall through to `randomFillSync`, which accepts any view.
110
+ }
111
+ }
112
+ if (typeof nodeCrypto.randomFillSync === 'function') {
113
+ nodeCrypto.randomFillSync(array);
114
+ return true;
115
+ }
116
+ return false;
49
117
  }
50
118
 
51
119
  const cryptoPolyfill: CryptoLike = {
52
120
  getRandomValues<T extends ArrayBufferView>(array: T): T {
53
- const bytes = getRandomBytesSync(array.byteLength);
121
+ // Node: back the CSPRNG with `node:crypto`. This is the path that matters on
122
+ // Node runtimes shipping WITHOUT a global WebCrypto — where delegating to
123
+ // the RN-only expo-crypto stub would throw.
124
+ if (isNodeJS() && fillFromNodeCrypto(array)) {
125
+ return array;
126
+ }
127
+ // React Native (and any non-Node host without WebCrypto): synchronous
128
+ // expo-crypto via @oxyhq/protocol's RN `platform/crypto` variant.
129
+ const bytes = getRandomBytesRN(array.byteLength);
54
130
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
55
131
  uint8View.set(bytes);
56
132
  return array;
@@ -15,6 +15,7 @@ import type {
15
15
  UserProfileUpdate,
16
16
  RecommendationRequest,
17
17
  RecommendationItem,
18
+ ThemePreference,
18
19
  } from '@oxyhq/contracts';
19
20
  import { recommendationRequestSchema } from '@oxyhq/contracts';
20
21
  import type { OxyServicesBase } from '../OxyServices.base';
@@ -628,6 +629,20 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
628
629
  return this.updateProfile({ userPreferences: preferences });
629
630
  }
630
631
 
632
+ /**
633
+ * Update the authenticated user's portable theme preference (light/dark/
634
+ * system + Bloom color-preset key). Persisted on the User document via the
635
+ * SAME `PUT /users/me` settings path as the other preferences — same cache
636
+ * invalidation — so the next cold boot serves it on the self/session payload
637
+ * with no extra network call. The full object is written (both `mode` and
638
+ * `colorPreset` are required by the API).
639
+ */
640
+ async updateThemePreference(
641
+ themePreference: ThemePreference
642
+ ): Promise<User> {
643
+ return this.updateProfile({ themePreference });
644
+ }
645
+
631
646
  /**
632
647
  * Request account verification
633
648
  */
@@ -1,4 +1,9 @@
1
- import type { OrganizationCategory, UserNameResponse } from '@oxyhq/contracts';
1
+ import type {
2
+ OrganizationCategory,
3
+ UserNameResponse,
4
+ UserRelationship,
5
+ ThemePreference,
6
+ } from '@oxyhq/contracts';
2
7
 
3
8
  export interface OxyConfig {
4
9
  baseURL: string;
@@ -163,6 +168,20 @@ export interface User {
163
168
  notificationPreferences?: NotificationPreferences;
164
169
  // General app-wide user preferences. Updated via `PUT /users/me`.
165
170
  userPreferences?: UserPreferences;
171
+ /**
172
+ * Portable theme preference (mode + Bloom color-preset key). Rides the
173
+ * self/session payload so it is present on the current-user DTO at cold boot
174
+ * (`useAuth().user.themePreference`) with no extra fetch. Absent until the
175
+ * user sets it. Updated via `updateThemePreference` / `updateProfile`.
176
+ */
177
+ themePreference?: ThemePreference;
178
+ /**
179
+ * The authenticated viewer's relationship to THIS profile. Populated ONLY on
180
+ * single-profile fetches (`getProfileByUsername` / `getUserById`) when the
181
+ * request is authenticated; absent for anonymous requests, self-views, and
182
+ * bulk fetches — so `undefined` means "unknown", not "not following".
183
+ */
184
+ relationship?: UserRelationship;
166
185
  [key: string]: unknown;
167
186
  }
168
187
 
@@ -390,6 +390,8 @@ export interface SafeFetchOptions {
390
390
  method?: string;
391
391
  /** Extra request headers. A `User-Agent` is added if none is provided. */
392
392
  headers?: Record<string, string>;
393
+ /** Request body for POST/PUT/PATCH. */
394
+ body?: string | Buffer;
393
395
  /**
394
396
  * Maximum number of redirects to follow (each re-validated). Defaults to
395
397
  * {@link MAX_REDIRECTS}. Set to `0` to disallow redirects.
@@ -475,6 +477,7 @@ function fetchOnce(
475
477
  options: https.RequestOptions,
476
478
  isHttps: boolean,
477
479
  headersTimeoutMs: number,
480
+ body?: string | Buffer,
478
481
  ): Promise<IncomingMessage> {
479
482
  return new Promise<IncomingMessage>((resolve, reject) => {
480
483
  const transport = isHttps ? https : http;
@@ -484,7 +487,7 @@ function fetchOnce(
484
487
  req.destroy(new UpstreamError('upstream headers timeout'));
485
488
  });
486
489
  req.on('error', (err) => reject(err));
487
- req.end();
490
+ req.end(body);
488
491
  });
489
492
  }
490
493
 
@@ -506,6 +509,7 @@ export async function safeFetch(
506
509
  const {
507
510
  method = 'GET',
508
511
  headers: callerHeaders,
512
+ body,
509
513
  maxRedirects = MAX_REDIRECTS,
510
514
  headersTimeoutMs = UPSTREAM_HEADERS_TIMEOUT_MS,
511
515
  signal,
@@ -548,6 +552,7 @@ export async function safeFetch(
548
552
  requestOptions,
549
553
  target.protocol === 'https:',
550
554
  headersTimeoutMs,
555
+ body,
551
556
  );
552
557
 
553
558
  const status = response.statusCode ?? 0;