@oxy.so/core 7.9.2 → 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.
package/src/index.ts CHANGED
@@ -206,6 +206,21 @@ export type {
206
206
  StorePageOptions,
207
207
  StoreReviewsOptions,
208
208
  } from './api/store';
209
+ // The store's installer (ADR 0053): what a runtime that installs applications registers beside
210
+ // `oxy.store`, and what a store asks it.
211
+ export {
212
+ getStoreInstaller,
213
+ setStoreInstaller,
214
+ StoreInstallerError,
215
+ } from './api/storeInstaller';
216
+ export type {
217
+ StoreInstallState,
218
+ StoreInstaller,
219
+ StoreInstallerFailure,
220
+ StoreUpdate,
221
+ StoreUpdates,
222
+ StoreUpdatesSource,
223
+ } from './api/storeInstaller';
209
224
  export type {
210
225
  BillingApi,
211
226
  SubscriptionPlan,
@@ -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