@oxyhq/core 12.4.0 → 12.5.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.
@@ -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.0",
3
+ "version": "12.5.0",
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",
@@ -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;
@@ -159,6 +159,26 @@ describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
159
159
  expect(await store.load()).toEqual(STORED);
160
160
  expect(signInWithSharedIdentity).not.toHaveBeenCalled();
161
161
  });
162
+
163
+ it('KEEPS the store on an UNRECOGNIZED 401 (proxy / middleware / deploy-window) — never wipes the credential on an ambiguous 401', async () => {
164
+ const store = createMemoryAuthStateStore();
165
+ await store.save(STORED);
166
+ const signInWithSharedIdentity = jest.fn(async () => null);
167
+ const { oxy } = makeOxy({
168
+ mintFromDeviceSecret: async () => {
169
+ // A 401 whose body is NEITHER `invalid_device_secret` NOR `no_active_session`
170
+ // — an auth-layer / proxy / starting-instance 401 common during a deploy
171
+ // window. It is NOT proof the secret diverged, so the durable device
172
+ // credential must survive (treated as transient) and a later attempt self-heals.
173
+ throw Object.assign(new Error('Unauthorized'), { status: 401 });
174
+ },
175
+ signInWithSharedIdentity,
176
+ });
177
+
178
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
179
+ expect(await store.load()).toEqual(STORED);
180
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
181
+ });
162
182
  });
163
183
 
164
184
  describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () => {
@@ -136,9 +136,18 @@ export async function refreshDeviceSecretArm(deps: {
136
136
  // Structural read (not `instanceof Error`): the thrown value can be a
137
137
  // plain ApiError-shaped object or come from another realm.
138
138
  const message = (error as { message?: unknown })?.message;
139
- return typeof message === 'string' && message.includes('no_active_session')
140
- ? { status: 'no-session' }
141
- : { status: 'invalid-secret' };
139
+ const body = typeof message === 'string' ? message : '';
140
+ // ONLY the server's explicit `invalid_device_secret` proves the presented
141
+ // secret is bad and may clear the durable device credential. `no_active_session`
142
+ // is an authoritative signed-out. ANY OTHER 401 — a middleware/CSRF/proxy 401,
143
+ // an ALB/starting-instance 401, a CORS error page, etc., all common during a
144
+ // deploy/restart window — is NOT proof the secret diverged: treat it as
145
+ // transient and KEEP the credential so a later attempt self-heals. Wiping the
146
+ // credential on an ambiguous 401 is what logged users out on every deploy,
147
+ // ecosystem-wide.
148
+ if (body.includes('invalid_device_secret')) return { status: 'invalid-secret' };
149
+ if (body.includes('no_active_session')) return { status: 'no-session' };
150
+ return { status: 'transient' };
142
151
  }
143
152
  return { status: 'transient' };
144
153
  }