@oxyhq/core 9.2.0 → 9.2.2

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.
Files changed (40) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +13 -0
  3. package/dist/cjs/index.js +6 -4
  4. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  5. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  6. package/dist/cjs/session/SessionClient.js +45 -0
  7. package/dist/cjs/session/accountDialogController.js +31 -0
  8. package/dist/cjs/session/authStateStore.js +196 -16
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/boot/sessionColdBoot.js +13 -0
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  13. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  14. package/dist/esm/session/SessionClient.js +45 -0
  15. package/dist/esm/session/accountDialogController.js +31 -0
  16. package/dist/esm/session/authStateStore.js +195 -15
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +2 -1
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/models/interfaces.d.ts +3 -1
  22. package/dist/types/models/session.d.ts +6 -0
  23. package/dist/types/session/SessionClient.d.ts +11 -0
  24. package/dist/types/session/accountDialogController.d.ts +17 -0
  25. package/dist/types/session/authStateStore.d.ts +33 -8
  26. package/package.json +2 -2
  27. package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
  28. package/src/boot/sessionColdBoot.ts +13 -0
  29. package/src/index.ts +3 -0
  30. package/src/mixins/OxyServices.accounts.ts +9 -0
  31. package/src/mixins/OxyServices.auth.ts +2 -0
  32. package/src/mixins/OxyServices.utility.ts +10 -9
  33. package/src/models/interfaces.ts +3 -1
  34. package/src/models/session.ts +6 -0
  35. package/src/session/SessionClient.ts +44 -0
  36. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  37. package/src/session/__tests__/accountDialogController.test.ts +93 -1
  38. package/src/session/__tests__/authStateStore.test.ts +170 -0
  39. package/src/session/accountDialogController.ts +54 -1
  40. package/src/session/authStateStore.ts +219 -15
@@ -109,6 +109,19 @@ async function runSessionColdBoot(opts) {
109
109
  if (!session?.accessToken) {
110
110
  return { kind: 'skip' };
111
111
  }
112
+ // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
113
+ // boot can use the faster device-secret-mint lane (sockets + tab-focus
114
+ // re-mint depend on the credential being in the store).
115
+ if (session.deviceId && session.deviceSecret) {
116
+ await store.save({
117
+ sessionId: session.sessionId,
118
+ userId: session.user.id,
119
+ deviceId: session.deviceId,
120
+ deviceSecret: session.deviceSecret,
121
+ accessToken: session.accessToken,
122
+ expiresAt: session.expiresAt,
123
+ });
124
+ }
112
125
  return {
113
126
  kind: 'session',
114
127
  session: {
package/dist/cjs/index.js CHANGED
@@ -18,10 +18,10 @@
18
18
  * If a symbol does not appear here, it is NOT part of the public API.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
- exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = 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 = void 0;
23
- exports.buildHubSyncUrl = exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_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.runColdBoot = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = 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.isValidDisplayName = void 0;
24
- exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isIdpHubOrigin = void 0;
21
+ exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
+ exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = 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 = void 0;
23
+ exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_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.runColdBoot = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = 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.isValidDisplayName = exports.isValidPassword = void 0;
24
+ exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -53,6 +53,8 @@ Object.defineProperty(exports, "getCanonicalUserHandle", { enumerable: true, get
53
53
  Object.defineProperty(exports, "getNormalizedUserHandle", { enumerable: true, get: function () { return userHandle_1.getNormalizedUserHandle; } });
54
54
  var profileLinks_1 = require("./utils/profileLinks");
55
55
  Object.defineProperty(exports, "normalizeProfileLinks", { enumerable: true, get: function () { return profileLinks_1.normalizeProfileLinks; } });
56
+ var OxyServices_accounts_1 = require("./mixins/OxyServices.accounts");
57
+ Object.defineProperty(exports, "ORGANIZATION_CATEGORIES", { enumerable: true, get: function () { return OxyServices_accounts_1.ORGANIZATION_CATEGORIES; } });
56
58
  // ---------------------------------------------------------------------------
57
59
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
58
60
  // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ORGANIZATION_CATEGORIES = void 0;
3
4
  exports.OxyServicesAccountsMixin = OxyServicesAccountsMixin;
4
5
  const userIdentity_1 = require("../utils/userIdentity");
5
6
  const mixinHelpers_1 = require("./mixinHelpers");
7
+ var contracts_1 = require("@oxyhq/contracts");
8
+ Object.defineProperty(exports, "ORGANIZATION_CATEGORIES", { enumerable: true, get: function () { return contracts_1.ORGANIZATION_CATEGORIES; } });
6
9
  function OxyServicesAccountsMixin(Base) {
7
10
  return class extends Base {
8
11
  constructor(...args) {
@@ -9,8 +9,8 @@ exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
9
9
  */
10
10
  const jwt_decode_1 = require("jwt-decode");
11
11
  const protocol_1 = require("@oxyhq/protocol");
12
+ const apiUtils_1 = require("../utils/apiUtils");
12
13
  const loggerUtils_1 = require("../utils/loggerUtils");
13
- const mixinHelpers_1 = require("./mixinHelpers");
14
14
  /**
15
15
  * Expected JWT audience for tokens issued by the Oxy auth service.
16
16
  */
@@ -111,10 +111,14 @@ function OxyServicesUtilityMixin(Base) {
111
111
  */
112
112
  async fetchLinkMetadata(url) {
113
113
  try {
114
- return await this.makeRequest('GET', '/link-metadata', { url }, {
115
- cache: true,
116
- cacheTTL: mixinHelpers_1.CACHE_TIMES.EXTRA_LONG,
117
- });
114
+ const path = (0, apiUtils_1.buildUrl)('/links/preview', { url, wait: 1 });
115
+ const preview = await this.makeRequest('GET', path, undefined, { cache: false });
116
+ return {
117
+ url: preview.url,
118
+ title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
119
+ description: preview.description?.trim() || 'Link',
120
+ image: preview.image,
121
+ };
118
122
  }
119
123
  catch (error) {
120
124
  throw this.handleError(error);
@@ -24,6 +24,10 @@ class SessionClient {
24
24
  this.started = false;
25
25
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
26
26
  this.channel = null;
27
+ /** App-facing subscriptions to named server-pushed socket events. */
28
+ this.serverEvents = new Map();
29
+ /** Event names already bound on the CURRENT socket instance. */
30
+ this.boundServerEvents = new Set();
27
31
  }
28
32
  getState() {
29
33
  return this.state;
@@ -34,6 +38,41 @@ class SessionClient {
34
38
  this.listeners.delete(listener);
35
39
  };
36
40
  }
41
+ /**
42
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
43
+ * Listeners survive reconnects and socket re-creation; the returned function
44
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
45
+ */
46
+ onServerEvent(event, listener) {
47
+ let listeners = this.serverEvents.get(event);
48
+ if (!listeners) {
49
+ listeners = new Set();
50
+ this.serverEvents.set(event, listeners);
51
+ }
52
+ listeners.add(listener);
53
+ this.bindServerEvent(event);
54
+ return () => {
55
+ listeners.delete(listener);
56
+ };
57
+ }
58
+ bindServerEvent(event) {
59
+ if (!this.socket || this.boundServerEvents.has(event))
60
+ return;
61
+ this.boundServerEvents.add(event);
62
+ this.socket.on(event, (payload) => {
63
+ const listeners = this.serverEvents.get(event);
64
+ if (!listeners)
65
+ return;
66
+ for (const listener of [...listeners]) {
67
+ try {
68
+ listener(payload);
69
+ }
70
+ catch (error) {
71
+ loggerUtils_1.logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
72
+ }
73
+ }
74
+ });
75
+ }
37
76
  notify() {
38
77
  for (const listener of this.listeners) {
39
78
  try {
@@ -213,6 +252,7 @@ class SessionClient {
213
252
  if (this.socket) {
214
253
  this.socket.disconnect();
215
254
  this.socket = null;
255
+ this.boundServerEvents.clear();
216
256
  }
217
257
  }
218
258
  async connectSocket() {
@@ -267,6 +307,11 @@ class SessionClient {
267
307
  }
268
308
  });
269
309
  this.socket = socket;
310
+ // (Re)bind app-facing server-event subscriptions on the fresh socket.
311
+ this.boundServerEvents.clear();
312
+ for (const event of this.serverEvents.keys()) {
313
+ this.bindServerEvent(event);
314
+ }
270
315
  }
271
316
  /**
272
317
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
@@ -34,6 +34,12 @@ const authWebUrl_1 = require("../utils/authWebUrl");
34
34
  const oauthPkce_1 = require("../utils/oauthPkce");
35
35
  const accountProjection_1 = require("./accountProjection");
36
36
  const DEFAULT_POLL_INTERVAL_MS = 3000;
37
+ /**
38
+ * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
39
+ * installed Commons on the same device; the `oxycommons://approve?...` deep link
40
+ * itself is the flow's `qrPayload`.
41
+ */
42
+ const COMMONS_APP_SCHEME = 'oxycommons://';
37
43
  const IDLE_SIGN_IN = {
38
44
  phase: 'idle',
39
45
  authorizeCode: null,
@@ -76,6 +82,7 @@ class AccountDialogController {
76
82
  this.authRedirectUri = options.authRedirectUri ?? null;
77
83
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
78
84
  this.openUrl = options.openUrl;
85
+ this.canOpenApp = options.canOpenApp;
79
86
  this.snapshot = this.computeSnapshot();
80
87
  }
81
88
  // =========================================================================
@@ -400,11 +407,34 @@ class AccountDialogController {
400
407
  error: null,
401
408
  });
402
409
  this.scheduleNextPoll(handle.sessionToken);
410
+ // Same-device convenience: if Commons is installed (native only — `canOpenApp`
411
+ // is undefined/false on web), deep-link straight into its approve screen with
412
+ // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
413
+ // stay live as the fallback, so a user who dismisses the app-open still
414
+ // completes the sign-in by scanning.
415
+ void this.maybeOpenCommons(handle.qrPayload);
403
416
  }
404
417
  catch (error) {
405
418
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
406
419
  }
407
420
  }
421
+ /**
422
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
423
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
424
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
425
+ */
426
+ async maybeOpenCommons(qrPayload) {
427
+ if (!this.canOpenApp || !this.openUrl)
428
+ return;
429
+ try {
430
+ if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
431
+ this.openUrl(qrPayload);
432
+ }
433
+ }
434
+ catch (error) {
435
+ loggerUtils_1.logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
436
+ }
437
+ }
408
438
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
409
439
  cancelSignIn() {
410
440
  this.clearPollTimer();
@@ -527,6 +557,7 @@ class AccountDialogController {
527
557
  expiresAt: claimed.expiresAt ?? '',
528
558
  user: minimalUser,
529
559
  accessToken: claimed.accessToken,
560
+ ...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
530
561
  }, minimalUser);
531
562
  }
532
563
  catch (error) {
@@ -18,16 +18,38 @@
18
18
  * ESM-safe (no `require()`).
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.AUTH_STATE_STORAGE_KEY = void 0;
21
+ exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
22
22
  exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
23
23
  exports.createWebAuthStateStore = createWebAuthStateStore;
24
24
  exports.createNativeAuthStateStore = createNativeAuthStateStore;
25
+ const loggerUtils_1 = require("../utils/loggerUtils");
25
26
  /**
26
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
27
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
28
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
27
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
28
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
29
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
30
+ * `expo-secure-store`, whose backing store can silently fail to persist an
31
+ * oversize value; bundling the token here previously took the mint credential
32
+ * down with it on every write, losing the session on cold restart.
33
+ *
34
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
35
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
36
+ * in `KeyManager`, so it never collides.
37
+ *
38
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
39
+ * `expiresAt`) into this single key. `load()` still reads those token fields
40
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
41
+ * so upgrading users are not signed out; the next `save()` splits them apart.
29
42
  */
30
43
  exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
44
+ /**
45
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
46
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
47
+ * failure (quota / oversize keychain value) is swallowed because the session is
48
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
49
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
50
+ * corrupt the durable credential write.
51
+ */
52
+ exports.AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
31
53
  /**
32
54
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
33
55
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -78,6 +100,95 @@ function deserialize(raw) {
78
100
  }
79
101
  return state;
80
102
  }
103
+ /**
104
+ * Parse the best-effort warm-token blob. Returns `null` for anything not a
105
+ * well-formed object so a corrupt warm entry simply forgoes the warm-boot
106
+ * optimization (the durable credential re-mints a fresh token).
107
+ */
108
+ function parseWarmToken(raw) {
109
+ if (!raw) {
110
+ return null;
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ if (!parsed || typeof parsed !== 'object') {
120
+ return null;
121
+ }
122
+ const candidate = parsed;
123
+ const warm = {};
124
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
125
+ warm.accessToken = candidate.accessToken;
126
+ }
127
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
128
+ warm.expiresAt = candidate.expiresAt;
129
+ }
130
+ return warm;
131
+ }
132
+ /** Serialize ONLY the small, re-mint-critical fields for the durable key. */
133
+ function serializeDurable(state) {
134
+ const durable = {
135
+ sessionId: state.sessionId,
136
+ userId: state.userId,
137
+ };
138
+ if (state.deviceId) {
139
+ durable.deviceId = state.deviceId;
140
+ }
141
+ if (state.deviceSecret) {
142
+ durable.deviceSecret = state.deviceSecret;
143
+ }
144
+ return JSON.stringify(durable);
145
+ }
146
+ /**
147
+ * Serialize the warm-token blob, or `null` when there is no token to persist
148
+ * (so the caller clears the warm key rather than writing an empty object).
149
+ */
150
+ function serializeWarmToken(state) {
151
+ const warm = {};
152
+ if (state.accessToken) {
153
+ warm.accessToken = state.accessToken;
154
+ }
155
+ if (state.expiresAt) {
156
+ warm.expiresAt = state.expiresAt;
157
+ }
158
+ if (!warm.accessToken && !warm.expiresAt) {
159
+ return null;
160
+ }
161
+ return JSON.stringify(warm);
162
+ }
163
+ /**
164
+ * Compose the unchanged {@link PersistedAuthState} return shape from the two
165
+ * on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
166
+ * present; when the warm key is ABSENT the token fields fall back to whatever
167
+ * the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
168
+ */
169
+ function composeState(durableRaw, warmRaw) {
170
+ const state = deserialize(durableRaw);
171
+ if (!state) {
172
+ return null;
173
+ }
174
+ if (warmRaw !== null) {
175
+ // New split layout: the warm key is authoritative for the token fields.
176
+ // Drop anything the durable blob may have carried, then overlay the warm
177
+ // values (a warm key with no token → the session simply has no warm token).
178
+ delete state.accessToken;
179
+ delete state.expiresAt;
180
+ const warm = parseWarmToken(warmRaw);
181
+ if (warm?.accessToken) {
182
+ state.accessToken = warm.accessToken;
183
+ }
184
+ if (warm?.expiresAt) {
185
+ state.expiresAt = warm.expiresAt;
186
+ }
187
+ }
188
+ // else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
189
+ // any `accessToken` / `expiresAt` from the old combined blob.
190
+ return state;
191
+ }
81
192
  /**
82
193
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
83
194
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -115,8 +226,9 @@ function safeGetLocalStorage() {
115
226
  }
116
227
  }
117
228
  /**
118
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
119
- * {@link AUTH_STATE_STORAGE_KEY}.
229
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
230
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
231
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
120
232
  *
121
233
  * Resilience:
122
234
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -143,7 +255,7 @@ function createWebAuthStateStore() {
143
255
  return sessionMirror;
144
256
  }
145
257
  try {
146
- return deserialize(storage.getItem(exports.AUTH_STATE_STORAGE_KEY));
258
+ return composeState(storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY));
147
259
  }
148
260
  catch {
149
261
  return null;
@@ -151,12 +263,35 @@ function createWebAuthStateStore() {
151
263
  },
152
264
  save: async (state) => {
153
265
  sessionMirror = state; // mirror FIRST — authoritative even if persist fails
266
+ // Durable credential FIRST, then VERIFY it landed. The in-memory mirror
267
+ // keeps the session live for this page, but a failed DURABLE write means
268
+ // the mint credential will NOT survive a reload — surface it, never swallow.
269
+ let durablePersisted = false;
270
+ try {
271
+ const durableJson = serializeDurable(state);
272
+ storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
273
+ durablePersisted = storage.getItem(exports.AUTH_STATE_STORAGE_KEY) === durableJson;
274
+ if (!durablePersisted) {
275
+ loggerUtils_1.logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload', undefined, { component: 'authStateStore' });
276
+ }
277
+ }
278
+ catch (error) {
279
+ loggerUtils_1.logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload', error, { component: 'authStateStore' });
280
+ }
281
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
282
+ // durable credential re-mints a fresh token) and must never abort or
283
+ // corrupt the durable write above.
154
284
  try {
155
- storage.setItem(exports.AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
285
+ const warmJson = serializeWarmToken(state);
286
+ if (warmJson) {
287
+ storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
288
+ }
289
+ else {
290
+ storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
291
+ }
156
292
  }
157
293
  catch {
158
- // Quota / private-mode / disabled storage — non-fatal. The session
159
- // stays live via the in-memory mirror; only reload durability is lost.
294
+ // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
160
295
  }
161
296
  },
162
297
  clear: async () => {
@@ -167,6 +302,12 @@ function createWebAuthStateStore() {
167
302
  catch {
168
303
  // Non-fatal — see save().
169
304
  }
305
+ try {
306
+ storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
307
+ }
308
+ catch {
309
+ // Non-fatal — see save().
310
+ }
170
311
  },
171
312
  };
172
313
  }
@@ -174,9 +315,12 @@ function createWebAuthStateStore() {
174
315
  * A native {@link AuthStateStore} over an injected async key/value store.
175
316
  *
176
317
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
177
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
178
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
179
- * exactly like the web store.
318
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
319
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
320
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
321
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
322
+ * while the warm-token write and all reads degrade gracefully exactly like the
323
+ * web store.
180
324
  */
181
325
  function createNativeAuthStateStore(storage) {
182
326
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
@@ -188,7 +332,11 @@ function createNativeAuthStateStore(storage) {
188
332
  return sessionMirror;
189
333
  }
190
334
  try {
191
- return deserialize(await storage.getItem(exports.AUTH_STATE_STORAGE_KEY));
335
+ const [durableRaw, warmRaw] = await Promise.all([
336
+ storage.getItem(exports.AUTH_STATE_STORAGE_KEY),
337
+ storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
338
+ ]);
339
+ return composeState(durableRaw, warmRaw);
192
340
  }
193
341
  catch {
194
342
  return null;
@@ -196,11 +344,37 @@ function createNativeAuthStateStore(storage) {
196
344
  },
197
345
  save: async (state) => {
198
346
  sessionMirror = state;
347
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
348
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
349
+ // only reliable proof. The mirror keeps the session live for this app run,
350
+ // but a failed DURABLE write means the mint credential will NOT survive a
351
+ // cold restart — surface it, never swallow.
352
+ let durablePersisted = false;
199
353
  try {
200
- await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
354
+ const durableJson = serializeDurable(state);
355
+ await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
356
+ durablePersisted = (await storage.getItem(exports.AUTH_STATE_STORAGE_KEY)) === durableJson;
357
+ if (!durablePersisted) {
358
+ loggerUtils_1.logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart', undefined, { component: 'authStateStore' });
359
+ }
360
+ }
361
+ catch (error) {
362
+ loggerUtils_1.logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart', error, { component: 'authStateStore' });
363
+ }
364
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
365
+ // durable credential re-mints a fresh token) and must never abort or
366
+ // corrupt the durable write above.
367
+ try {
368
+ const warmJson = serializeWarmToken(state);
369
+ if (warmJson) {
370
+ await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
371
+ }
372
+ else {
373
+ await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
374
+ }
201
375
  }
202
376
  catch {
203
- // Non-fatal session stays live via the in-memory mirror.
377
+ // Locked / oversize keychain non-fatal warm-boot loss only.
204
378
  }
205
379
  },
206
380
  clear: async () => {
@@ -211,6 +385,12 @@ function createNativeAuthStateStore(storage) {
211
385
  catch {
212
386
  // Non-fatal.
213
387
  }
388
+ try {
389
+ await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
390
+ }
391
+ catch {
392
+ // Non-fatal.
393
+ }
214
394
  },
215
395
  };
216
396
  }