@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.
@@ -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
  /**
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The store's installer: how a store installs, opens and updates an application on a machine
3
+ * that installs applications, beside `oxy.store`, which reads the catalogue.
4
+ *
5
+ * `oxy.store` says what the store lists; an installer says what the machine can do with a
6
+ * listing. A runtime that installs applications registers one (OxyOS's Bloom host registers one
7
+ * backed by the system's install service before the application's code runs, OxyOS ADR 0030
8
+ * decision 9; ADR 0053 here). Where none is registered — the web, iOS, Android — there is
9
+ * nothing to install, and a store shows what it shows without one. An application never asks
10
+ * which platform it runs on: it asks whether there is an installer.
11
+ *
12
+ * The installer is the store's hands, never its judge. It is given a listing's slug and nothing
13
+ * else: the machine resolves slug → package → release from the store's signed metadata, verifies
14
+ * it, and asks the person in a sheet of its own that no application can draw or press. The store
15
+ * learns the outcome and nothing of the sheet. Its errors say whether the machine already told the
16
+ * person (`told`), so a store never repeats what the system said.
17
+ *
18
+ * React-free and dependency-free, as everything in `@oxy.so/core`.
19
+ */
20
+ /** Where a listing stands on this machine, for the action a store's page shows. */
21
+ export type StoreInstallState =
22
+ /** The store has a release this machine can run, and it is not installed: *Get*. */
23
+ {
24
+ state: 'notInstalled';
25
+ }
26
+ /** Being installed or updated: a progress ring. `fraction` is null while it cannot be told. */
27
+ | {
28
+ state: 'installing';
29
+ fraction: number | null;
30
+ }
31
+ /** Installed and current: *Open*. */
32
+ | {
33
+ state: 'installed';
34
+ version: string;
35
+ }
36
+ /**
37
+ * Installed, with a newer release: *Update*. `needsConsent` when the newer release asks for
38
+ * more than the installed one, which the machine asks the person for in its own sheet.
39
+ */
40
+ | {
41
+ state: 'updateAvailable';
42
+ version: string;
43
+ newVersion: string;
44
+ needsConsent: boolean;
45
+ }
46
+ /** The store has no release this machine can run: *Not available for this computer*. */
47
+ | {
48
+ state: 'unavailable';
49
+ };
50
+ /** Why the machine could not do what the store asked. */
51
+ export type StoreInstallerFailure =
52
+ /** The machine's install service is not running. */
53
+ 'serviceUnavailable'
54
+ /** The store has no release of it this machine can run. */
55
+ | 'notAvailable'
56
+ /** It is not installed (opening or updating it). */
57
+ | 'notInstalled'
58
+ /** The installer refused the request (it lacks the right to install, or it was not a person's press). */
59
+ | 'refused'
60
+ /** The install or update itself failed (a download, a verification, a compile). */
61
+ | 'failed';
62
+ /** A failure of an installer's request. */
63
+ export declare class StoreInstallerError extends Error {
64
+ readonly reason: StoreInstallerFailure;
65
+ /**
66
+ * Whether the machine already told the person, in its own sheet. A store says nothing more
67
+ * when it did: the system's word is the one the person reads.
68
+ */
69
+ readonly told: boolean;
70
+ constructor(reason: StoreInstallerFailure, options?: {
71
+ told?: boolean;
72
+ message?: string;
73
+ });
74
+ }
75
+ /** An update the machine has for an installed application. */
76
+ export interface StoreUpdate {
77
+ /** The installer's own id for the application; what `apply` takes. Never a slug. */
78
+ id: string;
79
+ /** Its name in the person's language, from the store's signed metadata. */
80
+ name: string;
81
+ /** The version installed, and the one the update brings. */
82
+ version: string;
83
+ newVersion: string;
84
+ /** Bytes the machine would fetch: only what it does not have already. */
85
+ downloadSize: number;
86
+ /** What is new, in the person's language, or null when the release says nothing. */
87
+ releaseNotes: string | null;
88
+ /** The update asks for more than the installed version: the machine asks the person first. */
89
+ needsConsent: boolean;
90
+ /** Applied, waiting for the running copy to quit: it runs at its next launch. */
91
+ atNextLaunch: boolean;
92
+ }
93
+ /** The machine's updates for this account: one state, which every page that shows updates reads. */
94
+ export interface StoreUpdates {
95
+ updates: StoreUpdate[];
96
+ /** A check of the store is running. */
97
+ checking: boolean;
98
+ /** When the store was last read (ISO 8601), or null. */
99
+ lastChecked: string | null;
100
+ /** The last check failed. */
101
+ checkFailed: boolean;
102
+ }
103
+ /** The machine's updates, as one state that changes by itself. */
104
+ export interface StoreUpdatesSource {
105
+ /** The state last told, or null before the machine has said. */
106
+ current(): StoreUpdates | null;
107
+ /**
108
+ * The state as it is now, asked of the machine without reading the store. Rejects with a
109
+ * `StoreInstallerError` when the machine cannot say (its install service is not running).
110
+ */
111
+ read(): Promise<StoreUpdates>;
112
+ /** Reads the store now. A check asked while one runs joins it. */
113
+ check(): Promise<StoreUpdates>;
114
+ /** Applies these updates; one that needs consent is asked in the machine's sheet first. */
115
+ apply(ids: readonly string[]): Promise<void>;
116
+ /** Applies every update, asking for consent in turn after the rest. */
117
+ applyAll(): Promise<void>;
118
+ /** Told each change; returns the unsubscribe. */
119
+ subscribe(listener: (updates: StoreUpdates) => void): () => void;
120
+ }
121
+ /**
122
+ * What a runtime that installs applications gives a store. Every action that changes anything is
123
+ * asked from a person's press: the machine refuses what the person did not ask for.
124
+ */
125
+ export interface StoreInstaller {
126
+ /**
127
+ * Where each listing stands. Rejects with a `StoreInstallerError` when the machine cannot say
128
+ * (its install service is not running): a store then shows its usual action and lets the
129
+ * machine say why when it is pressed.
130
+ */
131
+ status(slugs: readonly string[]): Promise<Record<string, StoreInstallState>>;
132
+ /**
133
+ * Installs a listing's application: the machine's sheet, then the install. Resolves when the
134
+ * person declined in the sheet or the install is done; the progress in between is told to
135
+ * `subscribe`'s listeners as `installing`.
136
+ */
137
+ install(slug: string): Promise<'installed' | 'declined'>;
138
+ /** Updates it, asking in the machine's sheet when the update needs the person's consent. */
139
+ update(slug: string): Promise<'updated' | 'declined'>;
140
+ /** Removes it and its data, after the machine's sheet. */
141
+ uninstall(slug: string): Promise<'removed' | 'declined'>;
142
+ /** Opens the installed application. */
143
+ open(slug: string): Promise<void>;
144
+ /** Told each listing whose state changed; returns the unsubscribe. */
145
+ subscribe(listener: (slug: string, state: StoreInstallState) => void): () => void;
146
+ /** The account's updates (a store's *Updates* page). */
147
+ readonly updates: StoreUpdatesSource;
148
+ }
149
+ /**
150
+ * Registers the machine's installer, or removes it with null. A runtime calls it once, before
151
+ * the application's code runs; nothing else should.
152
+ */
153
+ export declare function setStoreInstaller(next: StoreInstaller | null): void;
154
+ /** The machine's installer, or null where nothing installs applications. */
155
+ export declare function getStoreInstaller(): StoreInstaller | null;
@@ -45,6 +45,8 @@ export type { AppsApi, AppAssertionsApi, Application, ApplicationType, Applicati
45
45
  export type { LinkedAccountsApi } from './api/linkedAccounts';
46
46
  export type { AgencyApi, AccountCapabilityPolicy, AvailableCapabilityCatalog, CapabilityExecutionAuthorization, CreateDelegationGrantInput, DelegationCatalogBinding, DelegationGrantView, PutAccountCapabilityPolicyInput, UpdateDelegationGrantInput, } from './api/agency';
47
47
  export type { StoreApi, StoreCategory, StoreRating, StoreListingSummary, StoreListingDetail, StoreScreenshot, StoreScreenshotPlatform, StoreReview, StoreOwnReview, WriteStoreReviewInput, StoreListingStatus, PublisherListing, WriteListingInput, AddScreenshotInput, UpdateScreenshotInput, StorePage, StorePageOptions, StoreReviewsOptions, } from './api/store';
48
+ export { getStoreInstaller, setStoreInstaller, StoreInstallerError, } from './api/storeInstaller';
49
+ export type { StoreInstallState, StoreInstaller, StoreInstallerFailure, StoreUpdate, StoreUpdates, StoreUpdatesSource, } from './api/storeInstaller';
48
50
  export type { BillingApi, SubscriptionPlan, SubscriptionStatus, SubscriptionFeatures, Subscription, Payment, WalletTransactionType, WalletTransactionStatus, WalletTransaction, WalletPagination, WalletTransactionsPage, } from './api/billing';
49
51
  export { REPUTATION_CACHE_PREFIX } from './api/reputation';
50
52
  export type { ReputationApi, ReputationPage } from './api/reputation';
@@ -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.9.2",
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",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The store's installer: how a store installs, opens and updates an application on a machine
3
+ * that installs applications, beside `oxy.store`, which reads the catalogue.
4
+ *
5
+ * `oxy.store` says what the store lists; an installer says what the machine can do with a
6
+ * listing. A runtime that installs applications registers one (OxyOS's Bloom host registers one
7
+ * backed by the system's install service before the application's code runs, OxyOS ADR 0030
8
+ * decision 9; ADR 0053 here). Where none is registered — the web, iOS, Android — there is
9
+ * nothing to install, and a store shows what it shows without one. An application never asks
10
+ * which platform it runs on: it asks whether there is an installer.
11
+ *
12
+ * The installer is the store's hands, never its judge. It is given a listing's slug and nothing
13
+ * else: the machine resolves slug → package → release from the store's signed metadata, verifies
14
+ * it, and asks the person in a sheet of its own that no application can draw or press. The store
15
+ * learns the outcome and nothing of the sheet. Its errors say whether the machine already told the
16
+ * person (`told`), so a store never repeats what the system said.
17
+ *
18
+ * React-free and dependency-free, as everything in `@oxy.so/core`.
19
+ */
20
+
21
+ /** Where a listing stands on this machine, for the action a store's page shows. */
22
+ export type StoreInstallState =
23
+ /** The store has a release this machine can run, and it is not installed: *Get*. */
24
+ | { state: 'notInstalled' }
25
+ /** Being installed or updated: a progress ring. `fraction` is null while it cannot be told. */
26
+ | { state: 'installing'; fraction: number | null }
27
+ /** Installed and current: *Open*. */
28
+ | { state: 'installed'; version: string }
29
+ /**
30
+ * Installed, with a newer release: *Update*. `needsConsent` when the newer release asks for
31
+ * more than the installed one, which the machine asks the person for in its own sheet.
32
+ */
33
+ | { state: 'updateAvailable'; version: string; newVersion: string; needsConsent: boolean }
34
+ /** The store has no release this machine can run: *Not available for this computer*. */
35
+ | { state: 'unavailable' };
36
+
37
+ /** Why the machine could not do what the store asked. */
38
+ export type StoreInstallerFailure =
39
+ /** The machine's install service is not running. */
40
+ | 'serviceUnavailable'
41
+ /** The store has no release of it this machine can run. */
42
+ | 'notAvailable'
43
+ /** It is not installed (opening or updating it). */
44
+ | 'notInstalled'
45
+ /** The installer refused the request (it lacks the right to install, or it was not a person's press). */
46
+ | 'refused'
47
+ /** The install or update itself failed (a download, a verification, a compile). */
48
+ | 'failed';
49
+
50
+ /** A failure of an installer's request. */
51
+ export class StoreInstallerError extends Error {
52
+ readonly reason: StoreInstallerFailure;
53
+ /**
54
+ * Whether the machine already told the person, in its own sheet. A store says nothing more
55
+ * when it did: the system's word is the one the person reads.
56
+ */
57
+ readonly told: boolean;
58
+
59
+ constructor(reason: StoreInstallerFailure, options: { told?: boolean; message?: string } = {}) {
60
+ super(options.message ?? reason);
61
+ this.name = 'StoreInstallerError';
62
+ this.reason = reason;
63
+ this.told = options.told ?? false;
64
+ }
65
+ }
66
+
67
+ /** An update the machine has for an installed application. */
68
+ export interface StoreUpdate {
69
+ /** The installer's own id for the application; what `apply` takes. Never a slug. */
70
+ id: string;
71
+ /** Its name in the person's language, from the store's signed metadata. */
72
+ name: string;
73
+ /** The version installed, and the one the update brings. */
74
+ version: string;
75
+ newVersion: string;
76
+ /** Bytes the machine would fetch: only what it does not have already. */
77
+ downloadSize: number;
78
+ /** What is new, in the person's language, or null when the release says nothing. */
79
+ releaseNotes: string | null;
80
+ /** The update asks for more than the installed version: the machine asks the person first. */
81
+ needsConsent: boolean;
82
+ /** Applied, waiting for the running copy to quit: it runs at its next launch. */
83
+ atNextLaunch: boolean;
84
+ }
85
+
86
+ /** The machine's updates for this account: one state, which every page that shows updates reads. */
87
+ export interface StoreUpdates {
88
+ updates: StoreUpdate[];
89
+ /** A check of the store is running. */
90
+ checking: boolean;
91
+ /** When the store was last read (ISO 8601), or null. */
92
+ lastChecked: string | null;
93
+ /** The last check failed. */
94
+ checkFailed: boolean;
95
+ }
96
+
97
+ /** The machine's updates, as one state that changes by itself. */
98
+ export interface StoreUpdatesSource {
99
+ /** The state last told, or null before the machine has said. */
100
+ current(): StoreUpdates | null;
101
+ /**
102
+ * The state as it is now, asked of the machine without reading the store. Rejects with a
103
+ * `StoreInstallerError` when the machine cannot say (its install service is not running).
104
+ */
105
+ read(): Promise<StoreUpdates>;
106
+ /** Reads the store now. A check asked while one runs joins it. */
107
+ check(): Promise<StoreUpdates>;
108
+ /** Applies these updates; one that needs consent is asked in the machine's sheet first. */
109
+ apply(ids: readonly string[]): Promise<void>;
110
+ /** Applies every update, asking for consent in turn after the rest. */
111
+ applyAll(): Promise<void>;
112
+ /** Told each change; returns the unsubscribe. */
113
+ subscribe(listener: (updates: StoreUpdates) => void): () => void;
114
+ }
115
+
116
+ /**
117
+ * What a runtime that installs applications gives a store. Every action that changes anything is
118
+ * asked from a person's press: the machine refuses what the person did not ask for.
119
+ */
120
+ export interface StoreInstaller {
121
+ /**
122
+ * Where each listing stands. Rejects with a `StoreInstallerError` when the machine cannot say
123
+ * (its install service is not running): a store then shows its usual action and lets the
124
+ * machine say why when it is pressed.
125
+ */
126
+ status(slugs: readonly string[]): Promise<Record<string, StoreInstallState>>;
127
+ /**
128
+ * Installs a listing's application: the machine's sheet, then the install. Resolves when the
129
+ * person declined in the sheet or the install is done; the progress in between is told to
130
+ * `subscribe`'s listeners as `installing`.
131
+ */
132
+ install(slug: string): Promise<'installed' | 'declined'>;
133
+ /** Updates it, asking in the machine's sheet when the update needs the person's consent. */
134
+ update(slug: string): Promise<'updated' | 'declined'>;
135
+ /** Removes it and its data, after the machine's sheet. */
136
+ uninstall(slug: string): Promise<'removed' | 'declined'>;
137
+ /** Opens the installed application. */
138
+ open(slug: string): Promise<void>;
139
+ /** Told each listing whose state changed; returns the unsubscribe. */
140
+ subscribe(listener: (slug: string, state: StoreInstallState) => void): () => void;
141
+ /** The account's updates (a store's *Updates* page). */
142
+ readonly updates: StoreUpdatesSource;
143
+ }
144
+
145
+ let installer: StoreInstaller | null = null;
146
+
147
+ /**
148
+ * Registers the machine's installer, or removes it with null. A runtime calls it once, before
149
+ * the application's code runs; nothing else should.
150
+ */
151
+ export function setStoreInstaller(next: StoreInstaller | null): void {
152
+ installer = next;
153
+ }
154
+
155
+ /** The machine's installer, or null where nothing installs applications. */
156
+ export function getStoreInstaller(): StoreInstaller | null {
157
+ return installer;
158
+ }