@oxy.so/core 7.10.0 → 7.10.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.
@@ -39,6 +39,8 @@ class SessionClient {
39
39
  this.tokenUnsub = null;
40
40
  this.started = false;
41
41
  this.lifecycleGeneration = 0;
42
+ /** Changes whenever the locally projected device changes, including ABA. */
43
+ this.deviceGeneration = 0;
42
44
  this.fullSignOutPersistence = null;
43
45
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
44
46
  this.channel = null;
@@ -68,6 +70,25 @@ class SessionClient {
68
70
  throw new Error('Device session request was superseded');
69
71
  return response;
70
72
  }
73
+ /** Capture the local device before a request, including device ABA. */
74
+ captureDeviceRequest() {
75
+ return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
76
+ }
77
+ isCurrentDeviceRequest(request, response) {
78
+ if (request.generation === this.deviceGeneration)
79
+ return true;
80
+ if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
81
+ return false;
82
+ if (response === null || typeof response !== 'object')
83
+ return false;
84
+ const record = response;
85
+ const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
86
+ return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
87
+ }
88
+ assertCurrentDeviceRequest(request, response) {
89
+ if (!this.isCurrentDeviceRequest(request, response))
90
+ throw new Error('Device session request was superseded');
91
+ }
71
92
  /** Stop and discard only this client's local device projection; no server revocation. */
72
93
  resetLocalState() {
73
94
  this.lifecycleGeneration += 1;
@@ -216,6 +237,8 @@ class SessionClient {
216
237
  return false;
217
238
  }
218
239
  const previousState = this.state;
240
+ if (previousState?.deviceId !== next.deviceId)
241
+ this.deviceGeneration += 1;
219
242
  this.state = next;
220
243
  if (next.accounts.length === 0) {
221
244
  // A device with nobody signed in has no principals either. The directory
@@ -324,11 +347,13 @@ class SessionClient {
324
347
  .ensureActiveToken(next)
325
348
  .then(finishApply)
326
349
  .catch((error) => {
327
- if (generation !== this.lifecycleGeneration)
350
+ if (generation !== this.lifecycleGeneration || this.state !== next)
328
351
  return;
329
352
  logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
330
353
  // Do NOT notify under a mismatched bearer. Revert to the last applied
331
354
  // state so subscribers keep observing the account whose token is planted.
355
+ if (this.state?.deviceId !== previousState?.deviceId)
356
+ this.deviceGeneration += 1;
332
357
  this.state = previousState ?? null;
333
358
  });
334
359
  }
@@ -378,6 +403,21 @@ class SessionClient {
378
403
  if (this.fullSignOutPersistence?.state === this.state)
379
404
  await this.fullSignOutPersistence.pending;
380
405
  }
406
+ /** Keep mutation callers from reporting success after a device change during persistence. */
407
+ async applySyncForMutation(raw) {
408
+ const before = this.captureDeviceRequest();
409
+ const lifecycle = this.lifecycleGeneration;
410
+ const checked = (0, client_1.deviceSessionSyncCheck)(raw, []);
411
+ const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
412
+ const expectedGeneration = before.generation + (responseDevice === before.initialDevice ? 0 : 1);
413
+ const pending = this.applySync(raw);
414
+ const generation = this.deviceGeneration;
415
+ await pending;
416
+ if (generation !== expectedGeneration ||
417
+ generation !== this.deviceGeneration ||
418
+ lifecycle !== this.lifecycleGeneration)
419
+ throw new Error('Device session request was superseded');
420
+ }
381
421
  /**
382
422
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
383
423
  * sync. Split out so the context-aware removal lane — whose response is a
@@ -397,14 +437,15 @@ class SessionClient {
397
437
  const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
398
438
  // Equal-revision restate (this revision was already applied by a preceding
399
439
  // socket push): `applyState` no-ops without planting, but the token still
400
- // needs planting. Guard on the sync's active account STILL being the current
401
- // active account so a stale response cannot adopt a token for an account a
402
- // newer state already switched away from — and, when pinned, on that account
403
- // being the PINNED one (same rule as the applied path in `applyState`).
440
+ // needs planting. Only the exact same device revision is an equal-revision
441
+ // restate: an older response may identify a different operator of the same
442
+ // account. Also require the current account and the identity pin to match.
404
443
  const pinnedAccountId = this.pinnedAccountId();
405
444
  if (!applied &&
406
445
  sync.activeToken &&
407
446
  this.state &&
447
+ sync.state.deviceId === this.state.deviceId &&
448
+ sync.state.revision === this.state.revision &&
408
449
  sync.state.activeAccountId !== null &&
409
450
  sync.state.activeAccountId === this.state.activeAccountId &&
410
451
  (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
@@ -454,9 +495,12 @@ class SessionClient {
454
495
  }
455
496
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
456
497
  async fetchDirectory() {
498
+ const request = this.captureDeviceRequest();
457
499
  const res = await this.requestDevice('GET', '/session/device/directory', undefined, {
458
500
  cache: false,
459
501
  });
502
+ if (!this.isCurrentDeviceRequest(request, res))
503
+ return false;
460
504
  return this.applyDirectory(res);
461
505
  }
462
506
  /**
@@ -518,14 +562,13 @@ class SessionClient {
518
562
  if (!accessToken) {
519
563
  return;
520
564
  }
565
+ const pinnedAccountId = this.pinnedAccountId();
521
566
  const subjectAccountId = (0, deviceDirectory_1.resolveActiveContext)(directory)?.subject.accountId ?? null;
522
567
  if (subjectAccountId === null) {
523
568
  return;
524
569
  }
525
- const pinnedAccountId = this.pinnedAccountId();
526
- if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
570
+ if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId)
527
571
  return;
528
- }
529
572
  if (accessToken === this.host.getAccessToken()) {
530
573
  return;
531
574
  }
@@ -565,9 +608,12 @@ class SessionClient {
565
608
  }
566
609
  }
567
610
  async bootstrap() {
611
+ const request = this.captureDeviceRequest();
568
612
  const res = await this.requestDevice('GET', '/session/device/state', undefined, {
569
613
  cache: false,
570
614
  });
615
+ if (!this.isCurrentDeviceRequest(request, res))
616
+ return;
571
617
  await this.applySync(res);
572
618
  }
573
619
  /**
@@ -611,13 +657,41 @@ class SessionClient {
611
657
  * compatibility path for callers still keyed on account ids.
612
658
  */
613
659
  async activateContext(contextId) {
660
+ const request = this.captureDeviceRequest();
614
661
  const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
662
+ this.assertCurrentDeviceRequest(request, res);
615
663
  const activation = (0, client_1.checkContract)(client_1.deviceActivateResponseCheck, res);
616
664
  if (!activation) {
617
665
  logger_1.logger.warn('[SessionClient] discarded invalid activation response');
618
666
  return;
619
667
  }
668
+ if (this.pinnedAccountId() === null) {
669
+ const active = (0, deviceDirectory_1.resolveActiveContext)(activation.directory);
670
+ if (!active || activation.directory.activeContextId !== contextId)
671
+ throw new Error('Device session request was superseded');
672
+ const claims = activation.activeToken?.accessToken
673
+ ? (0, tokenClaims_1.decodeTokenClaims)(activation.activeToken.accessToken)
674
+ : null;
675
+ const contextClaim = claims?.device_context_id;
676
+ const actor = claims?.act;
677
+ const actorClaim = actor !== null && typeof actor === 'object' ? actor.sub : undefined;
678
+ const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
679
+ // Decoded claims are only a consistency check, never authorization.
680
+ // Legacy and opaque bearers may omit these claims entirely.
681
+ if ((typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
682
+ (typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
683
+ (typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId))
684
+ throw new Error('Device session request was superseded');
685
+ }
620
686
  const known = this.knownRevisionFor(activation.directory.deviceId);
687
+ if (known !== null && activation.directory.revision < known)
688
+ throw new Error('Device session request was superseded');
689
+ const held = this.directory;
690
+ if (this.pinnedAccountId() === null &&
691
+ held?.deviceId === activation.directory.deviceId &&
692
+ held.revision === activation.directory.revision &&
693
+ held.activeContextId !== activation.directory.activeContextId)
694
+ throw new Error('Device session request was superseded');
621
695
  const moved = known === null || activation.directory.revision > known;
622
696
  this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
623
697
  // Applied BEFORE the reconcile, and only then: the reconcile's own
@@ -627,8 +701,21 @@ class SessionClient {
627
701
  // already holding in hand.
628
702
  const applied = this.applyDirectory(activation.directory);
629
703
  if (moved) {
704
+ const beforeReconcile = this.captureDeviceRequest();
705
+ const lifecycle = this.lifecycleGeneration;
630
706
  await this.reconcileFlatState(activation.directory);
707
+ const landedOnTarget = beforeReconcile.initialDevice !== activation.directory.deviceId &&
708
+ this.deviceGeneration === beforeReconcile.generation + 1 &&
709
+ this.state?.deviceId === activation.directory.deviceId;
710
+ if (lifecycle !== this.lifecycleGeneration ||
711
+ (this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget))
712
+ throw new Error('Device session request was superseded');
631
713
  }
714
+ if (this.pinnedAccountId() === null &&
715
+ (this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
716
+ this.directory?.deviceId !== activation.directory.deviceId ||
717
+ this.directory.activeContextId !== contextId))
718
+ throw new Error('Device session request was superseded');
632
719
  if (applied) {
633
720
  this.notify();
634
721
  }
@@ -637,8 +724,18 @@ class SessionClient {
637
724
  }
638
725
  }
639
726
  async switchAccount(accountId) {
727
+ const request = this.captureDeviceRequest();
640
728
  const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
641
- await this.applySync(res);
729
+ this.assertCurrentDeviceRequest(request, res);
730
+ const checked = (0, client_1.deviceSessionSyncCheck)(res, []);
731
+ const isStale = () => checked.ok &&
732
+ this.state?.deviceId === checked.value.state.deviceId &&
733
+ this.state.revision > checked.value.state.revision;
734
+ if (isStale())
735
+ throw new Error('Device session request was superseded');
736
+ await this.applySyncForMutation(res);
737
+ if (isStale())
738
+ throw new Error('Device session request was superseded');
642
739
  this.postCommitPing();
643
740
  }
644
741
  /**
@@ -653,10 +750,12 @@ class SessionClient {
653
750
  * {@link signOutPrincipal} for the two that can tell those apart.
654
751
  */
655
752
  async signOut(target) {
753
+ const request = this.captureDeviceRequest();
656
754
  const res = await this.requestDevice('POST', '/session/device/signout', target, {
657
755
  cache: false,
658
756
  });
659
- await this.applySync(res);
757
+ this.assertCurrentDeviceRequest(request, res);
758
+ await this.applySyncForMutation(res);
660
759
  this.postCommitPing();
661
760
  if (this.state?.accounts.length === 0)
662
761
  await this.persistFullSignOut();
@@ -698,21 +797,34 @@ class SessionClient {
698
797
  * applied this revision.
699
798
  */
700
799
  async removeFromDevice(target) {
800
+ const request = this.captureDeviceRequest();
701
801
  const res = await this.requestDevice('POST', '/session/device/signout', target, {
702
802
  cache: false,
703
803
  });
804
+ this.assertCurrentDeviceRequest(request, res);
704
805
  const removal = (0, client_1.checkContract)(client_1.deviceDirectorySyncCheck, res);
705
806
  if (!removal) {
706
807
  logger_1.logger.warn('[SessionClient] discarded invalid device removal response');
707
808
  return;
708
809
  }
709
810
  const directoryApplied = this.applyDirectory(removal.directory);
811
+ const beforeCommit = this.captureDeviceRequest();
812
+ const commitLifecycle = this.lifecycleGeneration;
813
+ const expectedGeneration = beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
710
814
  const stateApplied = this.commitSync({
711
815
  state: removal.state,
712
816
  activeToken: removal.activeToken,
713
817
  });
714
- if (this.fullSignOutPersistence?.state === this.state)
818
+ if (expectedGeneration !== this.deviceGeneration ||
819
+ commitLifecycle !== this.lifecycleGeneration)
820
+ throw new Error('Device session request was superseded');
821
+ if (this.fullSignOutPersistence?.state === this.state) {
822
+ const generation = this.deviceGeneration;
823
+ const lifecycle = this.lifecycleGeneration;
715
824
  await this.fullSignOutPersistence.pending;
825
+ if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
826
+ throw new Error('Device session request was superseded');
827
+ }
716
828
  // `commitSync` publishes whenever the flat state moved. When only the
717
829
  // directory did — a socket push already applied this revision — the
718
830
  // directory half would otherwise never reach a subscriber.
@@ -724,10 +836,12 @@ class SessionClient {
724
836
  await this.persistFullSignOut();
725
837
  }
726
838
  async addCurrentAccount() {
839
+ const request = this.captureDeviceRequest();
727
840
  const res = await this.requestDevice('POST', '/session/device/add', undefined, {
728
841
  cache: false,
729
842
  });
730
- await this.applySync(res);
843
+ this.assertCurrentDeviceRequest(request, res);
844
+ await this.applySyncForMutation(res);
731
845
  this.postCommitPing();
732
846
  }
733
847
  /**
@@ -36,6 +36,8 @@ export class SessionClient {
36
36
  this.tokenUnsub = null;
37
37
  this.started = false;
38
38
  this.lifecycleGeneration = 0;
39
+ /** Changes whenever the locally projected device changes, including ABA. */
40
+ this.deviceGeneration = 0;
39
41
  this.fullSignOutPersistence = null;
40
42
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
41
43
  this.channel = null;
@@ -65,6 +67,25 @@ export class SessionClient {
65
67
  throw new Error('Device session request was superseded');
66
68
  return response;
67
69
  }
70
+ /** Capture the local device before a request, including device ABA. */
71
+ captureDeviceRequest() {
72
+ return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
73
+ }
74
+ isCurrentDeviceRequest(request, response) {
75
+ if (request.generation === this.deviceGeneration)
76
+ return true;
77
+ if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
78
+ return false;
79
+ if (response === null || typeof response !== 'object')
80
+ return false;
81
+ const record = response;
82
+ const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
83
+ return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
84
+ }
85
+ assertCurrentDeviceRequest(request, response) {
86
+ if (!this.isCurrentDeviceRequest(request, response))
87
+ throw new Error('Device session request was superseded');
88
+ }
68
89
  /** Stop and discard only this client's local device projection; no server revocation. */
69
90
  resetLocalState() {
70
91
  this.lifecycleGeneration += 1;
@@ -213,6 +234,8 @@ export class SessionClient {
213
234
  return false;
214
235
  }
215
236
  const previousState = this.state;
237
+ if (previousState?.deviceId !== next.deviceId)
238
+ this.deviceGeneration += 1;
216
239
  this.state = next;
217
240
  if (next.accounts.length === 0) {
218
241
  // A device with nobody signed in has no principals either. The directory
@@ -321,11 +344,13 @@ export class SessionClient {
321
344
  .ensureActiveToken(next)
322
345
  .then(finishApply)
323
346
  .catch((error) => {
324
- if (generation !== this.lifecycleGeneration)
347
+ if (generation !== this.lifecycleGeneration || this.state !== next)
325
348
  return;
326
349
  logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
327
350
  // Do NOT notify under a mismatched bearer. Revert to the last applied
328
351
  // state so subscribers keep observing the account whose token is planted.
352
+ if (this.state?.deviceId !== previousState?.deviceId)
353
+ this.deviceGeneration += 1;
329
354
  this.state = previousState ?? null;
330
355
  });
331
356
  }
@@ -375,6 +400,21 @@ export class SessionClient {
375
400
  if (this.fullSignOutPersistence?.state === this.state)
376
401
  await this.fullSignOutPersistence.pending;
377
402
  }
403
+ /** Keep mutation callers from reporting success after a device change during persistence. */
404
+ async applySyncForMutation(raw) {
405
+ const before = this.captureDeviceRequest();
406
+ const lifecycle = this.lifecycleGeneration;
407
+ const checked = deviceSessionSyncCheck(raw, []);
408
+ const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
409
+ const expectedGeneration = before.generation + (responseDevice === before.initialDevice ? 0 : 1);
410
+ const pending = this.applySync(raw);
411
+ const generation = this.deviceGeneration;
412
+ await pending;
413
+ if (generation !== expectedGeneration ||
414
+ generation !== this.deviceGeneration ||
415
+ lifecycle !== this.lifecycleGeneration)
416
+ throw new Error('Device session request was superseded');
417
+ }
378
418
  /**
379
419
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
380
420
  * sync. Split out so the context-aware removal lane — whose response is a
@@ -394,14 +434,15 @@ export class SessionClient {
394
434
  const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
395
435
  // Equal-revision restate (this revision was already applied by a preceding
396
436
  // socket push): `applyState` no-ops without planting, but the token still
397
- // needs planting. Guard on the sync's active account STILL being the current
398
- // active account so a stale response cannot adopt a token for an account a
399
- // newer state already switched away from — and, when pinned, on that account
400
- // being the PINNED one (same rule as the applied path in `applyState`).
437
+ // needs planting. Only the exact same device revision is an equal-revision
438
+ // restate: an older response may identify a different operator of the same
439
+ // account. Also require the current account and the identity pin to match.
401
440
  const pinnedAccountId = this.pinnedAccountId();
402
441
  if (!applied &&
403
442
  sync.activeToken &&
404
443
  this.state &&
444
+ sync.state.deviceId === this.state.deviceId &&
445
+ sync.state.revision === this.state.revision &&
405
446
  sync.state.activeAccountId !== null &&
406
447
  sync.state.activeAccountId === this.state.activeAccountId &&
407
448
  (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
@@ -451,9 +492,12 @@ export class SessionClient {
451
492
  }
452
493
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
453
494
  async fetchDirectory() {
495
+ const request = this.captureDeviceRequest();
454
496
  const res = await this.requestDevice('GET', '/session/device/directory', undefined, {
455
497
  cache: false,
456
498
  });
499
+ if (!this.isCurrentDeviceRequest(request, res))
500
+ return false;
457
501
  return this.applyDirectory(res);
458
502
  }
459
503
  /**
@@ -515,14 +559,13 @@ export class SessionClient {
515
559
  if (!accessToken) {
516
560
  return;
517
561
  }
562
+ const pinnedAccountId = this.pinnedAccountId();
518
563
  const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
519
564
  if (subjectAccountId === null) {
520
565
  return;
521
566
  }
522
- const pinnedAccountId = this.pinnedAccountId();
523
- if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
567
+ if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId)
524
568
  return;
525
- }
526
569
  if (accessToken === this.host.getAccessToken()) {
527
570
  return;
528
571
  }
@@ -562,9 +605,12 @@ export class SessionClient {
562
605
  }
563
606
  }
564
607
  async bootstrap() {
608
+ const request = this.captureDeviceRequest();
565
609
  const res = await this.requestDevice('GET', '/session/device/state', undefined, {
566
610
  cache: false,
567
611
  });
612
+ if (!this.isCurrentDeviceRequest(request, res))
613
+ return;
568
614
  await this.applySync(res);
569
615
  }
570
616
  /**
@@ -608,13 +654,41 @@ export class SessionClient {
608
654
  * compatibility path for callers still keyed on account ids.
609
655
  */
610
656
  async activateContext(contextId) {
657
+ const request = this.captureDeviceRequest();
611
658
  const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
659
+ this.assertCurrentDeviceRequest(request, res);
612
660
  const activation = checkContract(deviceActivateResponseCheck, res);
613
661
  if (!activation) {
614
662
  logger.warn('[SessionClient] discarded invalid activation response');
615
663
  return;
616
664
  }
665
+ if (this.pinnedAccountId() === null) {
666
+ const active = resolveActiveContext(activation.directory);
667
+ if (!active || activation.directory.activeContextId !== contextId)
668
+ throw new Error('Device session request was superseded');
669
+ const claims = activation.activeToken?.accessToken
670
+ ? decodeTokenClaims(activation.activeToken.accessToken)
671
+ : null;
672
+ const contextClaim = claims?.device_context_id;
673
+ const actor = claims?.act;
674
+ const actorClaim = actor !== null && typeof actor === 'object' ? actor.sub : undefined;
675
+ const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
676
+ // Decoded claims are only a consistency check, never authorization.
677
+ // Legacy and opaque bearers may omit these claims entirely.
678
+ if ((typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
679
+ (typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
680
+ (typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId))
681
+ throw new Error('Device session request was superseded');
682
+ }
617
683
  const known = this.knownRevisionFor(activation.directory.deviceId);
684
+ if (known !== null && activation.directory.revision < known)
685
+ throw new Error('Device session request was superseded');
686
+ const held = this.directory;
687
+ if (this.pinnedAccountId() === null &&
688
+ held?.deviceId === activation.directory.deviceId &&
689
+ held.revision === activation.directory.revision &&
690
+ held.activeContextId !== activation.directory.activeContextId)
691
+ throw new Error('Device session request was superseded');
618
692
  const moved = known === null || activation.directory.revision > known;
619
693
  this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
620
694
  // Applied BEFORE the reconcile, and only then: the reconcile's own
@@ -624,8 +698,21 @@ export class SessionClient {
624
698
  // already holding in hand.
625
699
  const applied = this.applyDirectory(activation.directory);
626
700
  if (moved) {
701
+ const beforeReconcile = this.captureDeviceRequest();
702
+ const lifecycle = this.lifecycleGeneration;
627
703
  await this.reconcileFlatState(activation.directory);
704
+ const landedOnTarget = beforeReconcile.initialDevice !== activation.directory.deviceId &&
705
+ this.deviceGeneration === beforeReconcile.generation + 1 &&
706
+ this.state?.deviceId === activation.directory.deviceId;
707
+ if (lifecycle !== this.lifecycleGeneration ||
708
+ (this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget))
709
+ throw new Error('Device session request was superseded');
628
710
  }
711
+ if (this.pinnedAccountId() === null &&
712
+ (this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
713
+ this.directory?.deviceId !== activation.directory.deviceId ||
714
+ this.directory.activeContextId !== contextId))
715
+ throw new Error('Device session request was superseded');
629
716
  if (applied) {
630
717
  this.notify();
631
718
  }
@@ -634,8 +721,18 @@ export class SessionClient {
634
721
  }
635
722
  }
636
723
  async switchAccount(accountId) {
724
+ const request = this.captureDeviceRequest();
637
725
  const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
638
- await this.applySync(res);
726
+ this.assertCurrentDeviceRequest(request, res);
727
+ const checked = deviceSessionSyncCheck(res, []);
728
+ const isStale = () => checked.ok &&
729
+ this.state?.deviceId === checked.value.state.deviceId &&
730
+ this.state.revision > checked.value.state.revision;
731
+ if (isStale())
732
+ throw new Error('Device session request was superseded');
733
+ await this.applySyncForMutation(res);
734
+ if (isStale())
735
+ throw new Error('Device session request was superseded');
639
736
  this.postCommitPing();
640
737
  }
641
738
  /**
@@ -650,10 +747,12 @@ export class SessionClient {
650
747
  * {@link signOutPrincipal} for the two that can tell those apart.
651
748
  */
652
749
  async signOut(target) {
750
+ const request = this.captureDeviceRequest();
653
751
  const res = await this.requestDevice('POST', '/session/device/signout', target, {
654
752
  cache: false,
655
753
  });
656
- await this.applySync(res);
754
+ this.assertCurrentDeviceRequest(request, res);
755
+ await this.applySyncForMutation(res);
657
756
  this.postCommitPing();
658
757
  if (this.state?.accounts.length === 0)
659
758
  await this.persistFullSignOut();
@@ -695,21 +794,34 @@ export class SessionClient {
695
794
  * applied this revision.
696
795
  */
697
796
  async removeFromDevice(target) {
797
+ const request = this.captureDeviceRequest();
698
798
  const res = await this.requestDevice('POST', '/session/device/signout', target, {
699
799
  cache: false,
700
800
  });
801
+ this.assertCurrentDeviceRequest(request, res);
701
802
  const removal = checkContract(deviceDirectorySyncCheck, res);
702
803
  if (!removal) {
703
804
  logger.warn('[SessionClient] discarded invalid device removal response');
704
805
  return;
705
806
  }
706
807
  const directoryApplied = this.applyDirectory(removal.directory);
808
+ const beforeCommit = this.captureDeviceRequest();
809
+ const commitLifecycle = this.lifecycleGeneration;
810
+ const expectedGeneration = beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
707
811
  const stateApplied = this.commitSync({
708
812
  state: removal.state,
709
813
  activeToken: removal.activeToken,
710
814
  });
711
- if (this.fullSignOutPersistence?.state === this.state)
815
+ if (expectedGeneration !== this.deviceGeneration ||
816
+ commitLifecycle !== this.lifecycleGeneration)
817
+ throw new Error('Device session request was superseded');
818
+ if (this.fullSignOutPersistence?.state === this.state) {
819
+ const generation = this.deviceGeneration;
820
+ const lifecycle = this.lifecycleGeneration;
712
821
  await this.fullSignOutPersistence.pending;
822
+ if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
823
+ throw new Error('Device session request was superseded');
824
+ }
713
825
  // `commitSync` publishes whenever the flat state moved. When only the
714
826
  // directory did — a socket push already applied this revision — the
715
827
  // directory half would otherwise never reach a subscriber.
@@ -721,10 +833,12 @@ export class SessionClient {
721
833
  await this.persistFullSignOut();
722
834
  }
723
835
  async addCurrentAccount() {
836
+ const request = this.captureDeviceRequest();
724
837
  const res = await this.requestDevice('POST', '/session/device/add', undefined, {
725
838
  cache: false,
726
839
  });
727
- await this.applySync(res);
840
+ this.assertCurrentDeviceRequest(request, res);
841
+ await this.applySyncForMutation(res);
728
842
  this.postCommitPing();
729
843
  }
730
844
  /**
@@ -110,6 +110,8 @@ export declare class SessionClient {
110
110
  private tokenUnsub;
111
111
  private started;
112
112
  private lifecycleGeneration;
113
+ /** Changes whenever the locally projected device changes, including ABA. */
114
+ private deviceGeneration;
113
115
  private fullSignOutPersistence;
114
116
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
115
117
  private channel;
@@ -120,6 +122,10 @@ export declare class SessionClient {
120
122
  constructor(host: SessionClientHost, options?: SessionClientOptions);
121
123
  private persistFullSignOut;
122
124
  private requestDevice;
125
+ /** Capture the local device before a request, including device ABA. */
126
+ private captureDeviceRequest;
127
+ private isCurrentDeviceRequest;
128
+ private assertCurrentDeviceRequest;
123
129
  /** Stop and discard only this client's local device projection; no server revocation. */
124
130
  resetLocalState(): void;
125
131
  getState(): DeviceSessionState | null;
@@ -189,6 +195,8 @@ export declare class SessionClient {
189
195
  * account that is no longer active.
190
196
  */
191
197
  private applySync;
198
+ /** Keep mutation callers from reporting success after a device change during persistence. */
199
+ private applySyncForMutation;
192
200
  /**
193
201
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
194
202
  * sync. Split out so the context-aware removal lane — whose response is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.10.0",
3
+ "version": "7.10.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",
@@ -149,6 +149,8 @@ export class SessionClient {
149
149
  private tokenUnsub: (() => void) | null = null;
150
150
  private started = false;
151
151
  private lifecycleGeneration = 0;
152
+ /** Changes whenever the locally projected device changes, including ABA. */
153
+ private deviceGeneration = 0;
152
154
  private fullSignOutPersistence: { state: DeviceSessionState; pending: Promise<void> } | null =
153
155
  null;
154
156
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
@@ -188,6 +190,36 @@ export class SessionClient {
188
190
  return response;
189
191
  }
190
192
 
193
+ /** Capture the local device before a request, including device ABA. */
194
+ private captureDeviceRequest(): { generation: number; initialDevice: string | null } {
195
+ return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
196
+ }
197
+
198
+ private isCurrentDeviceRequest(
199
+ request: { generation: number; initialDevice: string | null },
200
+ response: unknown,
201
+ ): boolean {
202
+ if (request.generation === this.deviceGeneration) return true;
203
+ if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
204
+ return false;
205
+ if (response === null || typeof response !== 'object') return false;
206
+ const record = response as {
207
+ deviceId?: unknown;
208
+ state?: { deviceId?: unknown };
209
+ directory?: { deviceId?: unknown };
210
+ };
211
+ const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
212
+ return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
213
+ }
214
+
215
+ private assertCurrentDeviceRequest(
216
+ request: { generation: number; initialDevice: string | null },
217
+ response: unknown,
218
+ ): void {
219
+ if (!this.isCurrentDeviceRequest(request, response))
220
+ throw new Error('Device session request was superseded');
221
+ }
222
+
191
223
  /** Stop and discard only this client's local device projection; no server revocation. */
192
224
  resetLocalState(): void {
193
225
  this.lifecycleGeneration += 1;
@@ -351,6 +383,7 @@ export class SessionClient {
351
383
  return false;
352
384
  }
353
385
  const previousState = this.state;
386
+ if (previousState?.deviceId !== next.deviceId) this.deviceGeneration += 1;
354
387
  this.state = next;
355
388
  if (next.accounts.length === 0) {
356
389
  // A device with nobody signed in has no principals either. The directory
@@ -465,7 +498,7 @@ export class SessionClient {
465
498
  .ensureActiveToken(next)
466
499
  .then(finishApply)
467
500
  .catch((error) => {
468
- if (generation !== this.lifecycleGeneration) return;
501
+ if (generation !== this.lifecycleGeneration || this.state !== next) return;
469
502
  logger.warn(
470
503
  '[SessionClient] ensureActiveToken failed — reverting session state',
471
504
  { component: 'SessionClient' },
@@ -473,6 +506,7 @@ export class SessionClient {
473
506
  );
474
507
  // Do NOT notify under a mismatched bearer. Revert to the last applied
475
508
  // state so subscribers keep observing the account whose token is planted.
509
+ if (this.state?.deviceId !== previousState?.deviceId) this.deviceGeneration += 1;
476
510
  this.state = previousState ?? null;
477
511
  });
478
512
  } else {
@@ -527,6 +561,25 @@ export class SessionClient {
527
561
  await this.fullSignOutPersistence.pending;
528
562
  }
529
563
 
564
+ /** Keep mutation callers from reporting success after a device change during persistence. */
565
+ private async applySyncForMutation(raw: unknown): Promise<void> {
566
+ const before = this.captureDeviceRequest();
567
+ const lifecycle = this.lifecycleGeneration;
568
+ const checked = deviceSessionSyncCheck(raw, []);
569
+ const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
570
+ const expectedGeneration =
571
+ before.generation + (responseDevice === before.initialDevice ? 0 : 1);
572
+ const pending = this.applySync(raw);
573
+ const generation = this.deviceGeneration;
574
+ await pending;
575
+ if (
576
+ generation !== expectedGeneration ||
577
+ generation !== this.deviceGeneration ||
578
+ lifecycle !== this.lifecycleGeneration
579
+ )
580
+ throw new Error('Device session request was superseded');
581
+ }
582
+
530
583
  /**
531
584
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
532
585
  * sync. Split out so the context-aware removal lane — whose response is a
@@ -546,15 +599,16 @@ export class SessionClient {
546
599
  const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
547
600
  // Equal-revision restate (this revision was already applied by a preceding
548
601
  // socket push): `applyState` no-ops without planting, but the token still
549
- // needs planting. Guard on the sync's active account STILL being the current
550
- // active account so a stale response cannot adopt a token for an account a
551
- // newer state already switched away from — and, when pinned, on that account
552
- // being the PINNED one (same rule as the applied path in `applyState`).
602
+ // needs planting. Only the exact same device revision is an equal-revision
603
+ // restate: an older response may identify a different operator of the same
604
+ // account. Also require the current account and the identity pin to match.
553
605
  const pinnedAccountId = this.pinnedAccountId();
554
606
  if (
555
607
  !applied &&
556
608
  sync.activeToken &&
557
609
  this.state &&
610
+ sync.state.deviceId === this.state.deviceId &&
611
+ sync.state.revision === this.state.revision &&
558
612
  sync.state.activeAccountId !== null &&
559
613
  sync.state.activeAccountId === this.state.activeAccountId &&
560
614
  (pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
@@ -609,9 +663,11 @@ export class SessionClient {
609
663
 
610
664
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
611
665
  private async fetchDirectory(): Promise<boolean> {
666
+ const request = this.captureDeviceRequest();
612
667
  const res = await this.requestDevice<unknown>('GET', '/session/device/directory', undefined, {
613
668
  cache: false,
614
669
  });
670
+ if (!this.isCurrentDeviceRequest(request, res)) return false;
615
671
  return this.applyDirectory(res);
616
672
  }
617
673
 
@@ -684,14 +740,12 @@ export class SessionClient {
684
740
  if (!accessToken) {
685
741
  return;
686
742
  }
743
+ const pinnedAccountId = this.pinnedAccountId();
687
744
  const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
688
745
  if (subjectAccountId === null) {
689
746
  return;
690
747
  }
691
- const pinnedAccountId = this.pinnedAccountId();
692
- if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
693
- return;
694
- }
748
+ if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) return;
695
749
  if (accessToken === this.host.getAccessToken()) {
696
750
  return;
697
751
  }
@@ -738,9 +792,11 @@ export class SessionClient {
738
792
  }
739
793
 
740
794
  async bootstrap(): Promise<void> {
795
+ const request = this.captureDeviceRequest();
741
796
  const res = await this.requestDevice<unknown>('GET', '/session/device/state', undefined, {
742
797
  cache: false,
743
798
  });
799
+ if (!this.isCurrentDeviceRequest(request, res)) return;
744
800
  await this.applySync(res);
745
801
  }
746
802
 
@@ -787,18 +843,51 @@ export class SessionClient {
787
843
  * compatibility path for callers still keyed on account ids.
788
844
  */
789
845
  async activateContext(contextId: string): Promise<void> {
846
+ const request = this.captureDeviceRequest();
790
847
  const res = await this.requestDevice<unknown>(
791
848
  'POST',
792
849
  '/session/device/activate',
793
850
  { contextId },
794
851
  { cache: false },
795
852
  );
853
+ this.assertCurrentDeviceRequest(request, res);
796
854
  const activation = checkContract(deviceActivateResponseCheck, res);
797
855
  if (!activation) {
798
856
  logger.warn('[SessionClient] discarded invalid activation response');
799
857
  return;
800
858
  }
859
+ if (this.pinnedAccountId() === null) {
860
+ const active = resolveActiveContext(activation.directory);
861
+ if (!active || activation.directory.activeContextId !== contextId)
862
+ throw new Error('Device session request was superseded');
863
+ const claims = activation.activeToken?.accessToken
864
+ ? decodeTokenClaims(activation.activeToken.accessToken)
865
+ : null;
866
+ const contextClaim = claims?.device_context_id;
867
+ const actor = claims?.act;
868
+ const actorClaim =
869
+ actor !== null && typeof actor === 'object' ? (actor as { sub?: unknown }).sub : undefined;
870
+ const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
871
+ // Decoded claims are only a consistency check, never authorization.
872
+ // Legacy and opaque bearers may omit these claims entirely.
873
+ if (
874
+ (typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
875
+ (typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
876
+ (typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId)
877
+ )
878
+ throw new Error('Device session request was superseded');
879
+ }
801
880
  const known = this.knownRevisionFor(activation.directory.deviceId);
881
+ if (known !== null && activation.directory.revision < known)
882
+ throw new Error('Device session request was superseded');
883
+ const held = this.directory;
884
+ if (
885
+ this.pinnedAccountId() === null &&
886
+ held?.deviceId === activation.directory.deviceId &&
887
+ held.revision === activation.directory.revision &&
888
+ held.activeContextId !== activation.directory.activeContextId
889
+ )
890
+ throw new Error('Device session request was superseded');
802
891
  const moved = known === null || activation.directory.revision > known;
803
892
  this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
804
893
  // Applied BEFORE the reconcile, and only then: the reconcile's own
@@ -808,8 +897,26 @@ export class SessionClient {
808
897
  // already holding in hand.
809
898
  const applied = this.applyDirectory(activation.directory);
810
899
  if (moved) {
900
+ const beforeReconcile = this.captureDeviceRequest();
901
+ const lifecycle = this.lifecycleGeneration;
811
902
  await this.reconcileFlatState(activation.directory);
903
+ const landedOnTarget =
904
+ beforeReconcile.initialDevice !== activation.directory.deviceId &&
905
+ this.deviceGeneration === beforeReconcile.generation + 1 &&
906
+ this.state?.deviceId === activation.directory.deviceId;
907
+ if (
908
+ lifecycle !== this.lifecycleGeneration ||
909
+ (this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget)
910
+ )
911
+ throw new Error('Device session request was superseded');
812
912
  }
913
+ if (
914
+ this.pinnedAccountId() === null &&
915
+ (this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
916
+ this.directory?.deviceId !== activation.directory.deviceId ||
917
+ this.directory.activeContextId !== contextId)
918
+ )
919
+ throw new Error('Device session request was superseded');
813
920
  if (applied) {
814
921
  this.notify();
815
922
  }
@@ -819,13 +926,22 @@ export class SessionClient {
819
926
  }
820
927
 
821
928
  async switchAccount(accountId: string): Promise<void> {
929
+ const request = this.captureDeviceRequest();
822
930
  const res = await this.requestDevice<unknown>(
823
931
  'POST',
824
932
  '/session/device/switch',
825
933
  { accountId },
826
934
  { cache: false },
827
935
  );
828
- await this.applySync(res);
936
+ this.assertCurrentDeviceRequest(request, res);
937
+ const checked = deviceSessionSyncCheck(res, []);
938
+ const isStale = () =>
939
+ checked.ok &&
940
+ this.state?.deviceId === checked.value.state.deviceId &&
941
+ this.state.revision > checked.value.state.revision;
942
+ if (isStale()) throw new Error('Device session request was superseded');
943
+ await this.applySyncForMutation(res);
944
+ if (isStale()) throw new Error('Device session request was superseded');
829
945
  this.postCommitPing();
830
946
  }
831
947
 
@@ -841,10 +957,12 @@ export class SessionClient {
841
957
  * {@link signOutPrincipal} for the two that can tell those apart.
842
958
  */
843
959
  async signOut(target: { accountId: string } | { all: true }): Promise<void> {
960
+ const request = this.captureDeviceRequest();
844
961
  const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, {
845
962
  cache: false,
846
963
  });
847
- await this.applySync(res);
964
+ this.assertCurrentDeviceRequest(request, res);
965
+ await this.applySyncForMutation(res);
848
966
  this.postCommitPing();
849
967
  if (this.state?.accounts.length === 0) await this.persistFullSignOut();
850
968
  }
@@ -890,21 +1008,37 @@ export class SessionClient {
890
1008
  private async removeFromDevice(
891
1009
  target: { contextId: string } | { principalId: string },
892
1010
  ): Promise<void> {
1011
+ const request = this.captureDeviceRequest();
893
1012
  const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, {
894
1013
  cache: false,
895
1014
  });
1015
+ this.assertCurrentDeviceRequest(request, res);
896
1016
  const removal = checkContract(deviceDirectorySyncCheck, res);
897
1017
  if (!removal) {
898
1018
  logger.warn('[SessionClient] discarded invalid device removal response');
899
1019
  return;
900
1020
  }
901
1021
  const directoryApplied = this.applyDirectory(removal.directory);
1022
+ const beforeCommit = this.captureDeviceRequest();
1023
+ const commitLifecycle = this.lifecycleGeneration;
1024
+ const expectedGeneration =
1025
+ beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
902
1026
  const stateApplied = this.commitSync({
903
1027
  state: removal.state,
904
1028
  activeToken: removal.activeToken,
905
1029
  });
906
- if (this.fullSignOutPersistence?.state === this.state)
1030
+ if (
1031
+ expectedGeneration !== this.deviceGeneration ||
1032
+ commitLifecycle !== this.lifecycleGeneration
1033
+ )
1034
+ throw new Error('Device session request was superseded');
1035
+ if (this.fullSignOutPersistence?.state === this.state) {
1036
+ const generation = this.deviceGeneration;
1037
+ const lifecycle = this.lifecycleGeneration;
907
1038
  await this.fullSignOutPersistence.pending;
1039
+ if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
1040
+ throw new Error('Device session request was superseded');
1041
+ }
908
1042
  // `commitSync` publishes whenever the flat state moved. When only the
909
1043
  // directory did — a socket push already applied this revision — the
910
1044
  // directory half would otherwise never reach a subscriber.
@@ -916,10 +1050,12 @@ export class SessionClient {
916
1050
  }
917
1051
 
918
1052
  async addCurrentAccount(): Promise<void> {
1053
+ const request = this.captureDeviceRequest();
919
1054
  const res = await this.requestDevice<unknown>('POST', '/session/device/add', undefined, {
920
1055
  cache: false,
921
1056
  });
922
- await this.applySync(res);
1057
+ this.assertCurrentDeviceRequest(request, res);
1058
+ await this.applySyncForMutation(res);
923
1059
  this.postCommitPing();
924
1060
  }
925
1061