@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/dist/cjs/api/storeInstaller.js +46 -0
- package/dist/cjs/index.js +10 -4
- package/dist/cjs/session/SessionClient.js +126 -12
- package/dist/esm/api/storeInstaller.js +40 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/session/SessionClient.js +126 -12
- package/dist/types/api/storeInstaller.d.ts +155 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/package.json +1 -1
- package/src/api/storeInstaller.ts +158 -0
- package/src/index.ts +15 -0
- package/src/session/SessionClient.ts +149 -13
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.
|
|
550
|
-
//
|
|
551
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1057
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
1058
|
+
await this.applySyncForMutation(res);
|
|
923
1059
|
this.postCommitPing();
|
|
924
1060
|
}
|
|
925
1061
|
|