@oxyhq/core 3.2.0 → 3.4.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.
Files changed (73) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +3 -1
  3. package/dist/cjs/HttpService.js +89 -0
  4. package/dist/cjs/OxyServices.js +1 -1
  5. package/dist/cjs/constants/version.js +1 -1
  6. package/dist/cjs/i18n/locales/en-US.json +44 -44
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -44
  8. package/dist/cjs/i18n/locales/locales/en-US.json +44 -44
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +44 -44
  10. package/dist/cjs/index.js +4 -0
  11. package/dist/cjs/mixins/OxyServices.applications.js +3 -1
  12. package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
  13. package/dist/cjs/mixins/OxyServices.workspaces.js +3 -1
  14. package/dist/cjs/mixins/index.js +2 -2
  15. package/dist/cjs/utils/accountUtils.js +12 -5
  16. package/dist/cjs/utils/ssoReturn.js +80 -33
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/AuthManager.js +3 -1
  19. package/dist/esm/HttpService.js +89 -0
  20. package/dist/esm/OxyServices.js +1 -1
  21. package/dist/esm/constants/version.js +1 -1
  22. package/dist/esm/i18n/locales/en-US.json +44 -44
  23. package/dist/esm/i18n/locales/es-ES.json +44 -44
  24. package/dist/esm/i18n/locales/locales/en-US.json +44 -44
  25. package/dist/esm/i18n/locales/locales/es-ES.json +44 -44
  26. package/dist/esm/index.js +4 -0
  27. package/dist/esm/mixins/OxyServices.applications.js +3 -1
  28. package/dist/esm/mixins/OxyServices.reputation.js +241 -0
  29. package/dist/esm/mixins/OxyServices.workspaces.js +3 -1
  30. package/dist/esm/mixins/index.js +2 -2
  31. package/dist/esm/utils/accountUtils.js +12 -5
  32. package/dist/esm/utils/ssoReturn.js +80 -33
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/HttpService.d.ts +57 -0
  35. package/dist/types/OxyServices.d.ts +1 -1
  36. package/dist/types/constants/version.d.ts +2 -2
  37. package/dist/types/index.d.ts +2 -1
  38. package/dist/types/mixins/OxyServices.applications.d.ts +8 -2
  39. package/dist/types/mixins/OxyServices.features.d.ts +0 -1
  40. package/dist/types/mixins/OxyServices.reputation.d.ts +436 -0
  41. package/dist/types/mixins/OxyServices.workspaces.d.ts +8 -2
  42. package/dist/types/mixins/index.d.ts +2 -2
  43. package/dist/types/models/interfaces.d.ts +15 -26
  44. package/dist/types/utils/accountUtils.d.ts +17 -4
  45. package/dist/types/utils/ssoReturn.d.ts +30 -9
  46. package/package.json +2 -1
  47. package/src/AuthManager.ts +3 -1
  48. package/src/HttpService.ts +91 -0
  49. package/src/OxyServices.ts +1 -1
  50. package/src/__tests__/httpServiceCache.test.ts +198 -0
  51. package/src/constants/version.ts +1 -1
  52. package/src/i18n/locales/en-US.json +44 -44
  53. package/src/i18n/locales/es-ES.json +44 -44
  54. package/src/index.ts +32 -4
  55. package/src/mixins/OxyServices.applications.ts +8 -2
  56. package/src/mixins/OxyServices.auth.ts +2 -1
  57. package/src/mixins/OxyServices.features.ts +0 -1
  58. package/src/mixins/OxyServices.reputation.ts +674 -0
  59. package/src/mixins/OxyServices.workspaces.ts +8 -2
  60. package/src/mixins/__tests__/reputation.test.ts +408 -0
  61. package/src/mixins/index.ts +3 -3
  62. package/src/models/interfaces.ts +16 -32
  63. package/src/utils/__tests__/accountUtils.test.ts +142 -0
  64. package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
  65. package/src/utils/accountUtils.ts +20 -5
  66. package/src/utils/ssoReturn.ts +98 -37
  67. package/dist/cjs/mixins/OxyServices.developer.js +0 -97
  68. package/dist/cjs/mixins/OxyServices.karma.js +0 -108
  69. package/dist/esm/mixins/OxyServices.developer.js +0 -94
  70. package/dist/esm/mixins/OxyServices.karma.js +0 -105
  71. package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
  72. package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
  73. package/src/mixins/OxyServices.karma.ts +0 -111
@@ -319,8 +319,8 @@ describe('consumeSsoReturn', () => {
319
319
  expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
320
320
  });
321
321
 
322
- describe('dest restore', () => {
323
- it('restores a same-origin destination when on the callback path, removes the dest key, and dispatches popstate', async () => {
322
+ describe('ok dest restore (soft replaceState + popstate)', () => {
323
+ it('restores a same-origin destination when on the callback path, removes the dest key, dispatches popstate, and does NOT hard-redirect', async () => {
324
324
  const oxy = okExchange();
325
325
  const storage = makeStorage({
326
326
  [ssoStateKey(ORIGIN)]: 's',
@@ -328,6 +328,7 @@ describe('consumeSsoReturn', () => {
328
328
  });
329
329
  const history = makeHistory();
330
330
  const dispatchPopState = jest.fn();
331
+ const hardRedirect = jest.fn();
331
332
 
332
333
  const result = await consumeSsoReturn(oxy, {
333
334
  isWeb: () => true,
@@ -338,6 +339,7 @@ describe('consumeSsoReturn', () => {
338
339
  }),
339
340
  history,
340
341
  dispatchPopState,
342
+ hardRedirect,
341
343
  });
342
344
 
343
345
  expect(result).toEqual(SESSION);
@@ -347,6 +349,8 @@ describe('consumeSsoReturn', () => {
347
349
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
348
350
  // URL-driven routers must be told the location changed.
349
351
  expect(dispatchPopState).toHaveBeenCalledTimes(1);
352
+ // ok preserves the in-memory session — NEVER a hard navigation.
353
+ expect(hardRedirect).not.toHaveBeenCalled();
350
354
  });
351
355
 
352
356
  it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
@@ -356,6 +360,7 @@ describe('consumeSsoReturn', () => {
356
360
  [ssoDestKey(ORIGIN)]: '/settings?tab=privacy',
357
361
  });
358
362
  const history = makeHistory();
363
+ const hardRedirect = jest.fn();
359
364
 
360
365
  await consumeSsoReturn(oxy, {
361
366
  isWeb: () => true,
@@ -365,20 +370,50 @@ describe('consumeSsoReturn', () => {
365
370
  pathname: SSO_CALLBACK_PATH,
366
371
  }),
367
372
  history,
373
+ hardRedirect,
368
374
  });
369
375
 
370
376
  const last = history.calls[history.calls.length - 1];
371
377
  expect(last?.[2]).toBe('/settings?tab=privacy');
372
378
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
379
+ expect(hardRedirect).not.toHaveBeenCalled();
373
380
  });
374
381
 
375
- it('rejects a cross-origin (attacker-planted) destination but still removes the dest key', async () => {
382
+ it('falls back to the app root on ok when NO dest is stored (soft replaceState to "/")', async () => {
383
+ const oxy = okExchange();
384
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
385
+ const history = makeHistory();
386
+ const dispatchPopState = jest.fn();
387
+ const hardRedirect = jest.fn();
388
+
389
+ const result = await consumeSsoReturn(oxy, {
390
+ isWeb: () => true,
391
+ storage,
392
+ location: makeLocation({
393
+ hash: '#oxy_sso=ok&code=c&state=s',
394
+ pathname: SSO_CALLBACK_PATH,
395
+ }),
396
+ history,
397
+ dispatchPopState,
398
+ hardRedirect,
399
+ });
400
+
401
+ expect(result).toEqual(SESSION);
402
+ // First replaceState = fragment strip; last = root fallback.
403
+ const last = history.calls[history.calls.length - 1];
404
+ expect(last?.[2]).toBe('/');
405
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
406
+ expect(hardRedirect).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it('falls back to the app root on ok when the stored dest is cross-origin', async () => {
376
410
  const oxy = okExchange();
377
411
  const storage = makeStorage({
378
412
  [ssoStateKey(ORIGIN)]: 's',
379
413
  [ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
380
414
  });
381
415
  const history = makeHistory();
416
+ const hardRedirect = jest.fn();
382
417
 
383
418
  await consumeSsoReturn(oxy, {
384
419
  isWeb: () => true,
@@ -388,14 +423,16 @@ describe('consumeSsoReturn', () => {
388
423
  pathname: SSO_CALLBACK_PATH,
389
424
  }),
390
425
  history,
426
+ hardRedirect,
391
427
  });
392
428
 
393
- // Only the fragment-strip replaceState should have run — no dest restore.
394
- expect(history.calls).toHaveLength(1);
429
+ const last = history.calls[history.calls.length - 1];
430
+ expect(last?.[2]).toBe('/');
395
431
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
432
+ expect(hardRedirect).not.toHaveBeenCalled();
396
433
  });
397
434
 
398
- it('rejects a protocol-relative cross-origin destination', async () => {
435
+ it('falls back to the app root on ok when the stored dest is protocol-relative cross-origin', async () => {
399
436
  const oxy = okExchange();
400
437
  const storage = makeStorage({
401
438
  [ssoStateKey(ORIGIN)]: 's',
@@ -413,7 +450,8 @@ describe('consumeSsoReturn', () => {
413
450
  history,
414
451
  });
415
452
 
416
- expect(history.calls).toHaveLength(1);
453
+ const last = history.calls[history.calls.length - 1];
454
+ expect(last?.[2]).toBe('/');
417
455
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
418
456
  });
419
457
 
@@ -424,6 +462,7 @@ describe('consumeSsoReturn', () => {
424
462
  [ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
425
463
  });
426
464
  const history = makeHistory();
465
+ const hardRedirect = jest.fn();
427
466
 
428
467
  await consumeSsoReturn(oxy, {
429
468
  isWeb: () => true,
@@ -434,15 +473,49 @@ describe('consumeSsoReturn', () => {
434
473
  search: '?a=1',
435
474
  }),
436
475
  history,
476
+ hardRedirect,
437
477
  });
438
478
 
439
479
  // Only the fragment strip ran.
440
480
  expect(history.calls).toHaveLength(1);
441
481
  expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
442
482
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
483
+ expect(hardRedirect).not.toHaveBeenCalled();
443
484
  });
485
+ });
444
486
 
445
- it('restores dest + dispatches popstate on a "none" outcome (no-stranding regression)', async () => {
487
+ describe('non-ok hard redirect (never strand on the callback path)', () => {
488
+ it('hard-redirects to the app root on a "none" outcome with NO dest stored', async () => {
489
+ const oxy = okExchange();
490
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
491
+ const history = makeHistory();
492
+ const dispatchPopState = jest.fn();
493
+ const hardRedirect = jest.fn();
494
+
495
+ const result = await consumeSsoReturn(oxy, {
496
+ isWeb: () => true,
497
+ storage,
498
+ location: makeLocation({
499
+ hash: '#oxy_sso=none&state=s',
500
+ pathname: SSO_CALLBACK_PATH,
501
+ }),
502
+ history,
503
+ dispatchPopState,
504
+ hardRedirect,
505
+ });
506
+
507
+ expect(result).toBeNull();
508
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
509
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
510
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
511
+ // Soft restore is NOT used on a non-ok outcome.
512
+ expect(dispatchPopState).not.toHaveBeenCalled();
513
+ // Loop-breaker flags must still be set.
514
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
515
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
516
+ });
517
+
518
+ it('hard-redirects to a same-origin dest on a "none" outcome', async () => {
446
519
  const oxy = okExchange();
447
520
  const storage = makeStorage({
448
521
  [ssoStateKey(ORIGIN)]: 's',
@@ -450,6 +523,7 @@ describe('consumeSsoReturn', () => {
450
523
  });
451
524
  const history = makeHistory();
452
525
  const dispatchPopState = jest.fn();
526
+ const hardRedirect = jest.fn();
453
527
 
454
528
  const result = await consumeSsoReturn(oxy, {
455
529
  isWeb: () => true,
@@ -460,28 +534,27 @@ describe('consumeSsoReturn', () => {
460
534
  }),
461
535
  history,
462
536
  dispatchPopState,
537
+ hardRedirect,
463
538
  });
464
539
 
465
540
  expect(result).toBeNull();
466
541
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
467
- // Last replaceState targets the captured dest — the user is returned.
468
- const last = history.calls[history.calls.length - 1];
469
- expect(last?.[2]).toBe('/explore?x=1#sec');
542
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
543
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/explore?x=1#sec`);
544
+ expect(dispatchPopState).not.toHaveBeenCalled();
470
545
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
471
- expect(dispatchPopState).toHaveBeenCalledTimes(1);
472
- // Loop-breaker flags must still be set.
473
546
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
474
547
  expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
475
548
  });
476
549
 
477
- it('restores dest + dispatches popstate on an "error" outcome', async () => {
550
+ it('hard-redirects to a same-origin dest on an "error" outcome', async () => {
478
551
  const oxy = okExchange();
479
552
  const storage = makeStorage({
480
553
  [ssoStateKey(ORIGIN)]: 's',
481
554
  [ssoDestKey(ORIGIN)]: `${ORIGIN}/feed`,
482
555
  });
483
556
  const history = makeHistory();
484
- const dispatchPopState = jest.fn();
557
+ const hardRedirect = jest.fn();
485
558
 
486
559
  const result = await consumeSsoReturn(oxy, {
487
560
  isWeb: () => true,
@@ -491,26 +564,25 @@ describe('consumeSsoReturn', () => {
491
564
  pathname: SSO_CALLBACK_PATH,
492
565
  }),
493
566
  history,
494
- dispatchPopState,
567
+ hardRedirect,
495
568
  });
496
569
 
497
570
  expect(result).toBeNull();
498
- const last = history.calls[history.calls.length - 1];
499
- expect(last?.[2]).toBe('/feed');
571
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
572
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/feed`);
500
573
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
501
- expect(dispatchPopState).toHaveBeenCalledTimes(1);
502
574
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
503
575
  expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
504
576
  });
505
577
 
506
- it('restores dest + dispatches popstate on a state mismatch', async () => {
578
+ it('hard-redirects to the dest on a state mismatch (CSRF)', async () => {
507
579
  const oxy = okExchange();
508
580
  const storage = makeStorage({
509
581
  [ssoStateKey(ORIGIN)]: 'expected',
510
582
  [ssoDestKey(ORIGIN)]: `${ORIGIN}/notifications`,
511
583
  });
512
584
  const history = makeHistory();
513
- const dispatchPopState = jest.fn();
585
+ const hardRedirect = jest.fn();
514
586
 
515
587
  const result = await consumeSsoReturn(oxy, {
516
588
  isWeb: () => true,
@@ -520,19 +592,100 @@ describe('consumeSsoReturn', () => {
520
592
  pathname: SSO_CALLBACK_PATH,
521
593
  }),
522
594
  history,
523
- dispatchPopState,
595
+ hardRedirect,
524
596
  });
525
597
 
526
598
  expect(result).toBeNull();
527
599
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
528
- const last = history.calls[history.calls.length - 1];
529
- expect(last?.[2]).toBe('/notifications');
600
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
601
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/notifications`);
530
602
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
531
- expect(dispatchPopState).toHaveBeenCalledTimes(1);
532
603
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
533
604
  });
534
605
 
535
- it('does NOT restore dest on a "none" outcome when not on the callback path', async () => {
606
+ it('hard-redirects to the app root when ok carries a code but the exchange fails', async () => {
607
+ const boom = new Error('exchange failed');
608
+ const oxy = {
609
+ exchangeSsoCode: jest.fn(async () => {
610
+ throw boom;
611
+ }),
612
+ };
613
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
614
+ const history = makeHistory();
615
+ const hardRedirect = jest.fn();
616
+ const onExchangeError = jest.fn();
617
+
618
+ const result = await consumeSsoReturn(oxy, {
619
+ isWeb: () => true,
620
+ storage,
621
+ location: makeLocation({
622
+ hash: '#oxy_sso=ok&code=c&state=s',
623
+ pathname: SSO_CALLBACK_PATH,
624
+ }),
625
+ history,
626
+ hardRedirect,
627
+ onExchangeError,
628
+ });
629
+
630
+ expect(result).toBeNull();
631
+ expect(onExchangeError).toHaveBeenCalledWith(boom);
632
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
633
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
634
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
635
+ });
636
+
637
+ it('hard-redirects to the app root when ok carries a code but the exchange returns no sessionId', async () => {
638
+ const oxy = {
639
+ exchangeSsoCode: jest.fn(async () => ({ sessionId: '' }) as SessionLoginResponse),
640
+ };
641
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
642
+ const history = makeHistory();
643
+ const hardRedirect = jest.fn();
644
+
645
+ const result = await consumeSsoReturn(oxy, {
646
+ isWeb: () => true,
647
+ storage,
648
+ location: makeLocation({
649
+ hash: '#oxy_sso=ok&code=c&state=s',
650
+ pathname: SSO_CALLBACK_PATH,
651
+ }),
652
+ history,
653
+ hardRedirect,
654
+ });
655
+
656
+ expect(result).toBeNull();
657
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
658
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
659
+ });
660
+
661
+ it('falls back to the app root on a "none" outcome when the dest is cross-origin', async () => {
662
+ const oxy = okExchange();
663
+ const storage = makeStorage({
664
+ [ssoStateKey(ORIGIN)]: 's',
665
+ [ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
666
+ });
667
+ const history = makeHistory();
668
+ const hardRedirect = jest.fn();
669
+
670
+ const result = await consumeSsoReturn(oxy, {
671
+ isWeb: () => true,
672
+ storage,
673
+ location: makeLocation({
674
+ hash: '#oxy_sso=none&state=s',
675
+ pathname: SSO_CALLBACK_PATH,
676
+ }),
677
+ history,
678
+ hardRedirect,
679
+ });
680
+
681
+ expect(result).toBeNull();
682
+ // Never honour the cross-origin dest — fall back to the same-origin root.
683
+ expect(hardRedirect).toHaveBeenCalledTimes(1);
684
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
685
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
686
+ });
687
+
688
+ it('does NOT hard-redirect on a "none" outcome when NOT on the callback path', async () => {
536
689
  const oxy = okExchange();
537
690
  const storage = makeStorage({
538
691
  [ssoStateKey(ORIGIN)]: 's',
@@ -540,6 +693,7 @@ describe('consumeSsoReturn', () => {
540
693
  });
541
694
  const history = makeHistory();
542
695
  const dispatchPopState = jest.fn();
696
+ const hardRedirect = jest.fn();
543
697
 
544
698
  const result = await consumeSsoReturn(oxy, {
545
699
  isWeb: () => true,
@@ -551,46 +705,52 @@ describe('consumeSsoReturn', () => {
551
705
  }),
552
706
  history,
553
707
  dispatchPopState,
708
+ hardRedirect,
554
709
  });
555
710
 
556
711
  expect(result).toBeNull();
557
- // Only the fragment strip ran — no dest restore off the callback path.
712
+ // Only the fragment strip ran — no navigation off the callback path.
558
713
  expect(history.calls).toHaveLength(1);
559
714
  expect(history.calls[0]?.[2]).toBe('/explore?a=1');
560
715
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
716
+ expect(hardRedirect).not.toHaveBeenCalled();
561
717
  expect(dispatchPopState).not.toHaveBeenCalled();
562
718
  });
563
719
 
564
- it('rejects a cross-origin dest on a "none" outcome and does NOT dispatch popstate', async () => {
720
+ it('does NOT hard-redirect on an "ok" outcome when NOT on the callback path', async () => {
565
721
  const oxy = okExchange();
566
722
  const storage = makeStorage({
567
723
  [ssoStateKey(ORIGIN)]: 's',
568
- [ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
724
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
569
725
  });
570
726
  const history = makeHistory();
571
727
  const dispatchPopState = jest.fn();
728
+ const hardRedirect = jest.fn();
572
729
 
573
730
  const result = await consumeSsoReturn(oxy, {
574
731
  isWeb: () => true,
575
732
  storage,
576
733
  location: makeLocation({
577
- hash: '#oxy_sso=none&state=s',
578
- pathname: SSO_CALLBACK_PATH,
734
+ hash: '#oxy_sso=ok&code=c&state=s',
735
+ pathname: '/feed',
736
+ search: '?tab=home',
579
737
  }),
580
738
  history,
581
739
  dispatchPopState,
740
+ hardRedirect,
582
741
  });
583
742
 
584
- expect(result).toBeNull();
585
- // Only the fragment-strip replaceState ran — the cross-origin dest is rejected.
743
+ expect(result).toEqual(SESSION);
744
+ // Only the fragment strip ran — no navigation off the callback path.
586
745
  expect(history.calls).toHaveLength(1);
587
- expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
746
+ expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
747
+ expect(hardRedirect).not.toHaveBeenCalled();
588
748
  expect(dispatchPopState).not.toHaveBeenCalled();
589
749
  });
590
750
  });
591
751
 
592
752
  describe('default dispatchPopState (jsdom)', () => {
593
- it('fires a real popstate listener after a "none" dest restore on the callback path', async () => {
753
+ it('fires a real popstate listener after an "ok" dest restore on the callback path', async () => {
594
754
  const oxy = okExchange();
595
755
  const storage = makeStorage({
596
756
  [ssoStateKey(ORIGIN)]: 's',
@@ -605,14 +765,14 @@ describe('consumeSsoReturn', () => {
605
765
  isWeb: () => true,
606
766
  storage,
607
767
  location: makeLocation({
608
- hash: '#oxy_sso=none&state=s',
768
+ hash: '#oxy_sso=ok&code=c&state=s',
609
769
  pathname: SSO_CALLBACK_PATH,
610
770
  }),
611
771
  history,
612
772
  // No injected dispatchPopState — exercise the real default.
613
773
  });
614
774
 
615
- expect(result).toBeNull();
775
+ expect(result).toEqual(SESSION);
616
776
  const last = history.calls[history.calls.length - 1];
617
777
  expect(last?.[2]).toBe('/dashboard?tab=home');
618
778
  expect(onPopState).toHaveBeenCalledTimes(1);
@@ -621,4 +781,36 @@ describe('consumeSsoReturn', () => {
621
781
  }
622
782
  });
623
783
  });
784
+
785
+ describe('default hardRedirect (feature-detected, never throws)', () => {
786
+ it('stays total (resolves null, sets loop-breaker flags) on a non-ok outcome with the real default seam', async () => {
787
+ const oxy = okExchange();
788
+ const storage = makeStorage({
789
+ [ssoStateKey(ORIGIN)]: 's',
790
+ [ssoDestKey(ORIGIN)]: '/dashboard',
791
+ });
792
+ const history = makeHistory();
793
+
794
+ // No injected `hardRedirect` — exercise the real default, which reads the
795
+ // jsdom `window.location.replace`. jsdom routes the resulting navigation
796
+ // to its virtual console (a "Not implemented" notice) and does NOT throw,
797
+ // so the function must remain total: resolve null and set the loop-breaker
798
+ // flags. (The injected-`hardRedirect` suite above asserts the precise
799
+ // target argument deterministically.)
800
+ await expect(
801
+ consumeSsoReturn(oxy, {
802
+ isWeb: () => true,
803
+ storage,
804
+ location: makeLocation({
805
+ hash: '#oxy_sso=none&state=s',
806
+ pathname: SSO_CALLBACK_PATH,
807
+ }),
808
+ history,
809
+ }),
810
+ ).resolves.toBeNull();
811
+
812
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
813
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
814
+ });
815
+ });
624
816
  });
@@ -31,6 +31,14 @@ export interface QuickAccount {
31
31
  /** Minimal user shape accepted by display-name helpers. Avoids importing the full User type. */
32
32
  export interface DisplayNameUserShape {
33
33
  name?: string | { first?: string; last?: string; full?: string; [key: string]: unknown };
34
+ /**
35
+ * Pre-resolved display name as emitted by the server's `displayName` virtual
36
+ * (raw `/users/me` responses). NOTE: the server virtual resolves to
37
+ * `username || truncatedPublicKey || 'Anonymous'` — it does NOT compose the
38
+ * structured `name`. It is therefore preferred only AFTER a real structured
39
+ * name, so a first-name-only account never collapses to its username/key.
40
+ */
41
+ displayName?: string;
34
42
  username?: string;
35
43
  publicKey?: string;
36
44
  }
@@ -49,11 +57,16 @@ export const formatPublicKeyHandle = (publicKey: string): string => {
49
57
  * Resolve a friendly display name for a user.
50
58
  *
51
59
  * Order of preference:
52
- * 1. `name.full`, or composed `name.first name.last`
60
+ * 1. `name.full`, or composed `name.first name.last` (FIRST-NAME-ONLY SAFE —
61
+ * a user with only a first name resolves to that first name, never to the
62
+ * lowercase username; this is the exact drift bug the auth app hit).
53
63
  * 2. `name` (when stored as a plain string)
54
- * 3. `username`
55
- * 4. `Account 0x12345678…` (derived from publicKey, when present)
56
- * 5. Translated fallback (e.g. "Unnamed")
64
+ * 3. `displayName` (server `displayName` virtual — `username || truncatedKey`).
65
+ * Placed AFTER the structured name on purpose: the server virtual ignores
66
+ * `name`, so preferring it first would re-introduce the first-only bug.
67
+ * 4. `username`
68
+ * 5. `Account 0x12345678…` (derived from publicKey, when present)
69
+ * 6. Translated fallback (e.g. "Unnamed")
57
70
  *
58
71
  * The translation key `common.unnamed` is used for the final fallback. If the
59
72
  * caller does not pass a locale, the default English translation is used.
@@ -64,7 +77,7 @@ export const getAccountDisplayName = (
64
77
  ): string => {
65
78
  if (!user) return translate(locale, 'common.unnamed');
66
79
 
67
- const { name, username, publicKey } = user;
80
+ const { name, displayName, username, publicKey } = user;
68
81
 
69
82
  if (name && typeof name === 'object') {
70
83
  if (typeof name.full === 'string' && name.full.trim()) return name.full.trim();
@@ -76,6 +89,8 @@ export const getAccountDisplayName = (
76
89
  return name.trim();
77
90
  }
78
91
 
92
+ if (typeof displayName === 'string' && displayName.trim()) return displayName.trim();
93
+
79
94
  if (typeof username === 'string' && username.trim()) return username.trim();
80
95
 
81
96
  if (typeof publicKey === 'string' && publicKey.length > 0) {