@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.
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * The store's installer: how a store installs, opens and updates an application on a machine
4
+ * that installs applications, beside `oxy.store`, which reads the catalogue.
5
+ *
6
+ * `oxy.store` says what the store lists; an installer says what the machine can do with a
7
+ * listing. A runtime that installs applications registers one (OxyOS's Bloom host registers one
8
+ * backed by the system's install service before the application's code runs, OxyOS ADR 0030
9
+ * decision 9; ADR 0053 here). Where none is registered — the web, iOS, Android — there is
10
+ * nothing to install, and a store shows what it shows without one. An application never asks
11
+ * which platform it runs on: it asks whether there is an installer.
12
+ *
13
+ * The installer is the store's hands, never its judge. It is given a listing's slug and nothing
14
+ * else: the machine resolves slug → package → release from the store's signed metadata, verifies
15
+ * it, and asks the person in a sheet of its own that no application can draw or press. The store
16
+ * learns the outcome and nothing of the sheet. Its errors say whether the machine already told the
17
+ * person (`told`), so a store never repeats what the system said.
18
+ *
19
+ * React-free and dependency-free, as everything in `@oxy.so/core`.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.StoreInstallerError = void 0;
23
+ exports.setStoreInstaller = setStoreInstaller;
24
+ exports.getStoreInstaller = getStoreInstaller;
25
+ /** A failure of an installer's request. */
26
+ class StoreInstallerError extends Error {
27
+ constructor(reason, options = {}) {
28
+ super(options.message ?? reason);
29
+ this.name = 'StoreInstallerError';
30
+ this.reason = reason;
31
+ this.told = options.told ?? false;
32
+ }
33
+ }
34
+ exports.StoreInstallerError = StoreInstallerError;
35
+ let installer = null;
36
+ /**
37
+ * Registers the machine's installer, or removes it with null. A runtime calls it once, before
38
+ * the application's code runs; nothing else should.
39
+ */
40
+ function setStoreInstaller(next) {
41
+ installer = next;
42
+ }
43
+ /** The machine's installer, or null where nothing installs applications. */
44
+ function getStoreInstaller() {
45
+ return installer;
46
+ }
package/dist/cjs/index.js CHANGED
@@ -21,10 +21,10 @@
21
21
  * If a symbol does not appear here, it is NOT part of the public API.
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
- exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = exports.isLocaleLoaded = exports.loadLocale = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = void 0;
26
- exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = void 0;
27
- exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = void 0;
24
+ exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
+ exports.isLocaleLoaded = exports.loadLocale = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = void 0;
26
+ exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = void 0;
27
+ exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = void 0;
28
28
  // The crypto shim MUST stay the first import. The root itself ships no crypto
29
29
  // library (`__tests__/rootEntryGraph.test.ts`), but `@oxy.so/protocol`'s root
30
30
  // entry (reached by the OAuth/PKCE and device helpers) does, and Metro does not
@@ -58,6 +58,12 @@ Object.defineProperty(exports, "MAX_ACCOUNT_CATEGORIES", { enumerable: true, get
58
58
  Object.defineProperty(exports, "SELECTABLE_ACCOUNT_CATEGORY_IDS", { enumerable: true, get: function () { return accounts_1.SELECTABLE_ACCOUNT_CATEGORY_IDS; } });
59
59
  Object.defineProperty(exports, "isSelectableAccountCategoryId", { enumerable: true, get: function () { return accounts_1.isSelectableAccountCategoryId; } });
60
60
  Object.defineProperty(exports, "kindAcceptsAccountCategories", { enumerable: true, get: function () { return accounts_1.kindAcceptsAccountCategories; } });
61
+ // The store's installer (ADR 0053): what a runtime that installs applications registers beside
62
+ // `oxy.store`, and what a store asks it.
63
+ var storeInstaller_1 = require("./api/storeInstaller");
64
+ Object.defineProperty(exports, "getStoreInstaller", { enumerable: true, get: function () { return storeInstaller_1.getStoreInstaller; } });
65
+ Object.defineProperty(exports, "setStoreInstaller", { enumerable: true, get: function () { return storeInstaller_1.setStoreInstaller; } });
66
+ Object.defineProperty(exports, "StoreInstallerError", { enumerable: true, get: function () { return storeInstaller_1.StoreInstallerError; } });
61
67
  var reputation_1 = require("./api/reputation");
62
68
  Object.defineProperty(exports, "REPUTATION_CACHE_PREFIX", { enumerable: true, get: function () { return reputation_1.REPUTATION_CACHE_PREFIX; } });
63
69
  var devices_1 = require("./api/devices");
@@ -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
  /**
@@ -0,0 +1,40 @@
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
+ /** A failure of an installer's request. */
21
+ export class StoreInstallerError extends Error {
22
+ constructor(reason, options = {}) {
23
+ super(options.message ?? reason);
24
+ this.name = 'StoreInstallerError';
25
+ this.reason = reason;
26
+ this.told = options.told ?? false;
27
+ }
28
+ }
29
+ let installer = null;
30
+ /**
31
+ * Registers the machine's installer, or removes it with null. A runtime calls it once, before
32
+ * the application's code runs; nothing else should.
33
+ */
34
+ export function setStoreInstaller(next) {
35
+ installer = next;
36
+ }
37
+ /** The machine's installer, or null where nothing installs applications. */
38
+ export function getStoreInstaller() {
39
+ return installer;
40
+ }
package/dist/esm/index.js CHANGED
@@ -34,6 +34,9 @@ export { ASSET_MULTIPART_UPLOAD_MAX_BYTES, assetUrlCacheTTL, MAX_UPLOAD_BYTES }
34
34
  export { DirectUploadInterruptedError } from './api/directUpload.js';
35
35
  export { buildUserDid } from './api/identity.js';
36
36
  export { ACCOUNT_CATEGORY_IDS, MAX_ACCOUNT_CATEGORIES, SELECTABLE_ACCOUNT_CATEGORY_IDS, isSelectableAccountCategoryId, kindAcceptsAccountCategories, } from './api/accounts.js';
37
+ // The store's installer (ADR 0053): what a runtime that installs applications registers beside
38
+ // `oxy.store`, and what a store asks it.
39
+ export { getStoreInstaller, setStoreInstaller, StoreInstallerError, } from './api/storeInstaller.js';
37
40
  export { REPUTATION_CACHE_PREFIX } from './api/reputation.js';
38
41
  export { AccountNotOnDeviceError } from './api/devices.js';
39
42
  export { OxyAppDataIdentifierError } from './api/appData.js';