@oxyhq/core 10.1.2 → 10.1.4

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.
@@ -551,7 +551,6 @@ export interface SecurityActivity {
551
551
  eventType: SecurityEventType;
552
552
  eventDescription: string;
553
553
  metadata?: Record<string, any>;
554
- ipAddress?: string;
555
554
  userAgent?: string;
556
555
  deviceId?: string;
557
556
  timestamp: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "10.1.2",
3
+ "version": "10.1.4",
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",
@@ -648,7 +648,6 @@ export interface SecurityActivity {
648
648
  eventType: SecurityEventType;
649
649
  eventDescription: string;
650
650
  metadata?: Record<string, any>;
651
- ipAddress?: string;
652
651
  userAgent?: string;
653
652
  deviceId?: string;
654
653
  timestamp: string;
@@ -4,6 +4,7 @@ import type { User } from '../../models/interfaces';
4
4
  import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
5
5
  import type { AccountNode } from '../../mixins/OxyServices.accounts';
6
6
  import { SessionClient, type SessionClientHost } from '../SessionClient';
7
+ import { logger } from '../../utils/loggerUtils';
7
8
  import {
8
9
  AccountDialogController,
9
10
  createAccountDialogController,
@@ -206,6 +207,7 @@ describe('AccountDialogController — account list', () => {
206
207
  });
207
208
 
208
209
  it('keeps device rows and surfaces the error when listAccounts fails', async () => {
210
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
209
211
  const { controller, oxy, sc } = makeHarness();
210
212
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
211
213
  oxy.getUsersByIds.mockResolvedValue([user('a1')]);
@@ -216,6 +218,40 @@ describe('AccountDialogController — account list', () => {
216
218
  const snap = controller.getSnapshot();
217
219
  expect(snap.error).toBe('graph boom');
218
220
  expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
221
+ // A genuine (non-401) failure STILL warns.
222
+ expect(warnSpy).toHaveBeenCalledWith(
223
+ '[AccountDialogController] listAccounts failed',
224
+ { component: 'AccountDialogController' },
225
+ expect.any(Error),
226
+ );
227
+ warnSpy.mockRestore();
228
+ });
229
+
230
+ it('treats a 401 from listAccounts as the signed-out edge — debug, no surfaced error, no warn', async () => {
231
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
232
+ const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
233
+ const { controller, oxy, sc } = makeHarness();
234
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
235
+ oxy.getUsersByIds.mockResolvedValue([user('a1')]);
236
+ // A stale/revoked bearer 401s: an EXPECTED signed-out outcome, not a failure.
237
+ oxy.listAccounts.mockRejectedValue(
238
+ Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
239
+ );
240
+
241
+ await controller.refresh();
242
+
243
+ const snap = controller.getSnapshot();
244
+ // Never surface an error for a normal signed-out state; device rows still render.
245
+ expect(snap.error).toBeNull();
246
+ expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
247
+ expect(warnSpy).not.toHaveBeenCalled();
248
+ expect(debugSpy).toHaveBeenCalledWith(
249
+ '[AccountDialogController] listAccounts unauthorized (signed out)',
250
+ { component: 'AccountDialogController' },
251
+ expect.objectContaining({ status: 401 }),
252
+ );
253
+ warnSpy.mockRestore();
254
+ debugSpy.mockRestore();
219
255
  });
220
256
  });
221
257
 
@@ -30,6 +30,7 @@ import type { OxyServices } from '../OxyServices';
30
30
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
31
31
  import type { User } from '../models/interfaces';
32
32
  import { logger } from '../utils/loggerUtils';
33
+ import { extractErrorStatus } from '../utils/errorUtils';
33
34
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
34
35
  import {
35
36
  generateOAuthState,
@@ -392,10 +393,19 @@ export class AccountDialogController {
392
393
  try {
393
394
  graph = await this.oxyServices.listAccounts();
394
395
  } catch (error) {
395
- // A graph-load failure is non-fatal: device rows still render. Surface the
396
- // message but keep going with whatever graph we already had.
397
- this.error = errorMessage(error);
398
- logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
396
+ // A 401 here is the EXPECTED signed-out edge, not a failure: the bearer was
397
+ // stale/revoked, so `HttpService` already cleared it and emitted
398
+ // `onTokensChanged(null)`, which drops the graph via `reconcileAuth`. Log at
399
+ // debug and leave the dialog error-free — a signed-out device with zero
400
+ // accounts is a normal state, not a warning. Any other error (network, 5xx,
401
+ // malformed) IS unexpected: surface it and warn while keeping the prior graph
402
+ // so device rows still render.
403
+ if (extractErrorStatus(error) === 401) {
404
+ logger.debug('[AccountDialogController] listAccounts unauthorized (signed out)', { component: 'AccountDialogController' }, error);
405
+ } else {
406
+ this.error = errorMessage(error);
407
+ logger.warn('[AccountDialogController] listAccounts failed', { component: 'AccountDialogController' }, error);
408
+ }
399
409
  }
400
410
  if (seq !== this.refreshSeq) return; // superseded by a newer refresh
401
411
 
@@ -432,9 +442,15 @@ export class AccountDialogController {
432
442
  try {
433
443
  profiles = await this.oxyServices.getUsersByIds(ids);
434
444
  } catch (error) {
435
- // `getUsersByIds` already swallows per-chunk failures and returns `[]`;
436
- // this guards the unexpected total failure. Non-fatal keep prior map.
437
- logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
445
+ // A 401 is the EXPECTED signed-out edge (stale/cleared bearer) — log at debug.
446
+ // `getUsersByIds` already swallows per-chunk failures and returns `[]`, so any
447
+ // OTHER error here is an unexpected total failure worth a warn. Either way keep
448
+ // the prior profile map.
449
+ if (extractErrorStatus(error) === 401) {
450
+ logger.debug('[AccountDialogController] getUsersByIds unauthorized (signed out)', { component: 'AccountDialogController' }, error);
451
+ } else {
452
+ logger.warn('[AccountDialogController] getUsersByIds failed', { component: 'AccountDialogController' }, error);
453
+ }
438
454
  return;
439
455
  }
440
456
  if (seq !== this.refreshSeq) return; // superseded
@@ -43,30 +43,6 @@
43
43
  * normalize whitespace and Unicode form.
44
44
  */
45
45
 
46
- /**
47
- * Characters that make a value ineligible for the zero-work fast path, and the
48
- * whitespace shapes that a normalized INLINE value can never contain.
49
- *
50
- * A value that matches nothing here is, by construction, already normalized:
51
- * it holds only printable ASCII (which is NFC-stable, so `normalize('NFC')`
52
- * would be a no-op), its only whitespace is the plain space, it has no leading
53
- * or trailing space, and no run of two spaces. Returning it untouched skips
54
- * three string allocations — worth it because the common case in the feed
55
- * hydration hot path is text that is already clean.
56
- *
57
- * Non-global (safe for repeated `.test()`; a global regex is stateful).
58
- */
59
- const INLINE_NEEDS_NORMALIZATION = /[^\x20-\x7E]|^ | $| {2}/;
60
-
61
- /**
62
- * Same idea as {@link INLINE_NEEDS_NORMALIZATION}, for MULTILINE values: `\n`
63
- * joins the printable-ASCII fast-path alphabet, and the additional shapes a
64
- * normalized body can never contain are a space adjacent to a line break — on
65
- * either side, since every line is trimmed — and a run of three line breaks
66
- * (more than one blank line).
67
- */
68
- const MULTILINE_NEEDS_NORMALIZATION = /[^\x20-\x7E\n]|^[ \n]|[ \n]$| {2}| \n|\n |\n{3}/;
69
-
70
46
  /** Any run of whitespace, including tabs, line breaks and Unicode spaces. */
71
47
  const ANY_WHITESPACE_RUN = /\s+/g;
72
48
 
@@ -134,9 +110,6 @@ const EXCESS_BLANK_LINES = /\n{3,}/g;
134
110
  * Idempotent: `f(f(x)) === f(x)`.
135
111
  */
136
112
  export function normalizeInlineText(value: string): string {
137
- if (!INLINE_NEEDS_NORMALIZATION.test(value)) {
138
- return value;
139
- }
140
113
  return value.normalize('NFC').replace(ANY_WHITESPACE_RUN, ' ').trim();
141
114
  }
142
115
 
@@ -173,9 +146,6 @@ export function normalizeInlineText(value: string): string {
173
146
  * Idempotent: `f(f(x)) === f(x)`.
174
147
  */
175
148
  export function normalizeMultilineText(value: string): string {
176
- if (!MULTILINE_NEEDS_NORMALIZATION.test(value)) {
177
- return value;
178
- }
179
149
  return value
180
150
  .normalize('NFC')
181
151
  .replace(LINE_BREAK_FORMS, '\n')