@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.
@@ -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;
@@ -67,9 +67,20 @@ export async function refreshDeviceSecretArm(deps) {
67
67
  // Structural read (not `instanceof Error`): the thrown value can be a
68
68
  // plain ApiError-shaped object or come from another realm.
69
69
  const message = error?.message;
70
- return typeof message === 'string' && message.includes('no_active_session')
71
- ? { status: 'no-session' }
72
- : { status: 'invalid-secret' };
70
+ const body = typeof message === 'string' ? message : '';
71
+ // ONLY the server's explicit `invalid_device_secret` proves the presented
72
+ // secret is bad and may clear the durable device credential. `no_active_session`
73
+ // is an authoritative signed-out. ANY OTHER 401 — a middleware/CSRF/proxy 401,
74
+ // an ALB/starting-instance 401, a CORS error page, etc., all common during a
75
+ // deploy/restart window — is NOT proof the secret diverged: treat it as
76
+ // transient and KEEP the credential so a later attempt self-heals. Wiping the
77
+ // credential on an ambiguous 401 is what logged users out on every deploy,
78
+ // ecosystem-wide.
79
+ if (body.includes('invalid_device_secret'))
80
+ return { status: 'invalid-secret' };
81
+ if (body.includes('no_active_session'))
82
+ return { status: 'no-session' };
83
+ return { status: 'transient' };
73
84
  }
74
85
  return { status: 'transient' };
75
86
  }