@oxyhq/core 3.13.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/index.js CHANGED
@@ -21,7 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  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.canonicalize = exports.computeRecordId = exports.signedRecordSigningInput = 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.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
22
  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 = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = void 0;
23
23
  exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = exports.registrableApex = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = 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.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = void 0;
24
- exports.packageInfo = exports.runColdBoot = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoAttemptedKey = exports.ssoNoSessionKey = void 0;
24
+ exports.packageInfo = exports.runColdBoot = exports.allowSsoBounce = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoPriorSessionKey = exports.ssoAttemptedKey = exports.ssoNoSessionKey = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -287,12 +287,14 @@ Object.defineProperty(exports, "ssoGuardKey", { enumerable: true, get: function
287
287
  Object.defineProperty(exports, "ssoDestKey", { enumerable: true, get: function () { return ssoBounce_1.ssoDestKey; } });
288
288
  Object.defineProperty(exports, "ssoNoSessionKey", { enumerable: true, get: function () { return ssoBounce_1.ssoNoSessionKey; } });
289
289
  Object.defineProperty(exports, "ssoAttemptedKey", { enumerable: true, get: function () { return ssoBounce_1.ssoAttemptedKey; } });
290
+ Object.defineProperty(exports, "ssoPriorSessionKey", { enumerable: true, get: function () { return ssoBounce_1.ssoPriorSessionKey; } });
290
291
  Object.defineProperty(exports, "ssoCallbackBootstrapKey", { enumerable: true, get: function () { return ssoBounce_1.ssoCallbackBootstrapKey; } });
291
292
  Object.defineProperty(exports, "ssoNavigate", { enumerable: true, get: function () { return ssoBounce_1.ssoNavigate; } });
292
293
  Object.defineProperty(exports, "getSsoCallbackBootstrapScript", { enumerable: true, get: function () { return ssoBounce_1.getSsoCallbackBootstrapScript; } });
293
294
  Object.defineProperty(exports, "buildSsoBounceUrl", { enumerable: true, get: function () { return ssoBounce_1.buildSsoBounceUrl; } });
294
295
  Object.defineProperty(exports, "isCentralIdPOrigin", { enumerable: true, get: function () { return ssoBounce_1.isCentralIdPOrigin; } });
295
296
  Object.defineProperty(exports, "guardActive", { enumerable: true, get: function () { return ssoBounce_1.guardActive; } });
297
+ Object.defineProperty(exports, "allowSsoBounce", { enumerable: true, get: function () { return ssoBounce_1.allowSsoBounce; } });
296
298
  var coldBoot_1 = require("./utils/coldBoot");
297
299
  Object.defineProperty(exports, "runColdBoot", { enumerable: true, get: function () { return coldBoot_1.runColdBoot; } });
298
300
  // API response contracts (request/response Zod schemas + inferred types) live in
@@ -26,6 +26,49 @@ function OxyServicesApplicationsMixin(Base) {
26
26
  throw this.handleError(error);
27
27
  }
28
28
  }
29
+ /**
30
+ * List the OAuth-authorized applications the current user has connected —
31
+ * the third-party apps the user granted access to via the consent flow.
32
+ * Each entry is a {@link ConnectedApp} carrying the application's display
33
+ * identity, the granted scopes, and when the grant was first made and last
34
+ * exercised. Requires an authenticated session.
35
+ *
36
+ * Backed by `GET /auth/grants`. The response is briefly cached
37
+ * (identity-scoped); {@link revokeAppGrant} busts that cache so a revoke is
38
+ * reflected on the next read.
39
+ */
40
+ async listConnectedApps() {
41
+ try {
42
+ return await this.makeRequest('GET', '/auth/grants', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
43
+ }
44
+ catch (error) {
45
+ throw this.handleError(error);
46
+ }
47
+ }
48
+ /**
49
+ * Revoke the current user's grant for a connected application, identified by
50
+ * its application `_id` (a {@link ConnectedApp.applicationId}, NOT a
51
+ * credential/client id — keyed by application so the revocation survives
52
+ * credential rotation). After this the application can no longer act on the
53
+ * user's behalf until it is re-authorized.
54
+ *
55
+ * Backed by `DELETE /auth/grants/:applicationId`. On success the cached
56
+ * connected-apps list (`GET:/auth/grants`) is invalidated so the next
57
+ * {@link listConnectedApps} read reflects the removal.
58
+ *
59
+ * @param applicationId - The connected application's Mongo `_id`.
60
+ */
61
+ async revokeAppGrant(applicationId) {
62
+ try {
63
+ await this.makeRequest('DELETE', `/auth/grants/${applicationId}`, undefined, { cache: false });
64
+ // A revoke removes an entry from the user's connected-apps list; bust
65
+ // the cached `GET /auth/grants` so the next read re-fetches.
66
+ this.clearCacheEntry('GET:/auth/grants');
67
+ }
68
+ catch (error) {
69
+ throw this.handleError(error);
70
+ }
71
+ }
29
72
  /**
30
73
  * List applications the current user is an active member of.
31
74
  *
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyServicesNodesMixin = OxyServicesNodesMixin;
4
+ const signatureService_1 = require("../crypto/signatureService");
5
+ const OxyServices_identity_1 = require("./OxyServices.identity");
6
+ const mixinHelpers_1 = require("./mixinHelpers");
7
+ /**
8
+ * AtProto-style collection (NSID) for a user-node registration record — matches
9
+ * the server's `NODE_COLLECTION`. A user has exactly one node, so the record is
10
+ * keyed by the constant {@link NODE_RKEY} (last-writer-wins): re-registering
11
+ * over-writes the single `self` record rather than appending a second node.
12
+ */
13
+ const NODE_COLLECTION = 'app.oxy.node';
14
+ /**
15
+ * The AtProto-style record key for the single node registration — matches the
16
+ * server's `NODE_RKEY`. Constant (`'self'`) because a user has one node.
17
+ */
18
+ const NODE_RKEY = 'self';
19
+ /**
20
+ * Cache-key prefix of every node read (`GET /nodes/me`). Swept after a
21
+ * register / revoke / managed-provision so a re-read reflects the new node
22
+ * (or its absence) instead of a stale cached one. The identity tag is a key
23
+ * SUFFIX, so this prefix invalidates the resource for every cached identity.
24
+ */
25
+ const NODES_CACHE_PREFIX = 'GET:/nodes/';
26
+ /**
27
+ * Cache-key prefix of the current user's `GET /users/me`. Swept alongside the
28
+ * node caches because the user's derived DID document embeds an `#oxy-node`
29
+ * service entry derived from the node row, so registering / revoking / managing
30
+ * a node changes user-facing identity state.
31
+ */
32
+ const USERS_ME_CACHE_PREFIX = 'GET:/users/me';
33
+ function OxyServicesNodesMixin(Base) {
34
+ return class extends Base {
35
+ constructor(...args) {
36
+ super(...args);
37
+ }
38
+ /**
39
+ * Register (or re-register) the caller's SELF-HOSTED personal data node.
40
+ *
41
+ * Builds the `{ endpoint, nodePublicKey, mode }` node record, signs a v2
42
+ * envelope on the caller's own per-subject hash chain (fetching the current
43
+ * chain head first so `seq`/`prev` are never stale), and publishes it through
44
+ * the EXISTING `POST /identity/records` path — which verifies the signature
45
+ * and materializes the operational node cache as a side effect. The signed
46
+ * record (not this call) is the authority; re-registering over-writes the
47
+ * single `self` record (last-writer-wins).
48
+ *
49
+ * NATIVE-ONLY: signs with the on-device identity key (throws on web / when no
50
+ * identity or no authenticated user — the guard fires before any network).
51
+ * `mode` defaults to `'pull'`. After a successful publish the node + `/users/me`
52
+ * GET caches are swept, then the freshly-materialized status is returned.
53
+ *
54
+ * Throws if the chain record stored but the server skipped materialization
55
+ * (e.g. a malformed endpoint the server rejected) — an unexpected state rather
56
+ * than a silent `null`.
57
+ *
58
+ * @param input - The node's endpoint, public key, and optional transport mode.
59
+ */
60
+ async registerNode(input) {
61
+ try {
62
+ const userId = this.getCurrentUserId();
63
+ if (!userId) {
64
+ throw new Error('No authenticated user — sign in before registering a node.');
65
+ }
66
+ const subject = (0, OxyServices_identity_1.buildUserDid)(userId);
67
+ const record = {
68
+ endpoint: input.endpoint,
69
+ nodePublicKey: input.nodePublicKey,
70
+ mode: input.mode ?? 'pull',
71
+ };
72
+ // Fetch the caller's chain head fresh (uncached) so seq/prev are correct
73
+ // → no bad_seq / chain_fork — exactly as the identity/civic signers do.
74
+ const head = await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/chain/head`, undefined, { cache: false });
75
+ const envelope = await signatureService_1.SignatureService.signRecordV2('node', subject, record, {
76
+ seq: head.seq + 1,
77
+ prev: head.headRecordId,
78
+ collection: NODE_COLLECTION,
79
+ rkey: NODE_RKEY,
80
+ });
81
+ await this.makeRequest('POST', '/identity/records', envelope, { cache: false });
82
+ this._sweepNodeCaches();
83
+ const node = await this.getMyNode();
84
+ if (!node) {
85
+ throw new Error('Node registration stored but the node could not be materialized.');
86
+ }
87
+ return node;
88
+ }
89
+ catch (error) {
90
+ throw this.handleError(error);
91
+ }
92
+ }
93
+ /**
94
+ * Read the caller's registered node status (`GET /nodes/me`), or `null` when
95
+ * the caller has no node. Auth required; short-TTL cached (the liveness badge
96
+ * is background-maintained) and swept after the caller's own
97
+ * register / revoke / managed-provision.
98
+ */
99
+ async getMyNode() {
100
+ try {
101
+ const res = await this.makeRequest('GET', '/nodes/me', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
102
+ return res.node ?? null;
103
+ }
104
+ catch (error) {
105
+ throw this.handleError(error);
106
+ }
107
+ }
108
+ /**
109
+ * Revoke the caller's node registration (`DELETE /nodes/me`). The node flips
110
+ * to `revoked` server-side (leaving the DID document and liveness sweeps).
111
+ * Auth required; the node + `/users/me` GET caches are swept on success.
112
+ *
113
+ * Maps the server's `{ success }` to the SDK's `{ revoked }` semantic.
114
+ */
115
+ async removeMyNode() {
116
+ try {
117
+ const res = await this.makeRequest('DELETE', '/nodes/me', undefined, { cache: false });
118
+ this._sweepNodeCaches();
119
+ return { revoked: res.success === true };
120
+ }
121
+ catch (error) {
122
+ throw this.handleError(error);
123
+ }
124
+ }
125
+ /**
126
+ * Provision (or refresh) an Oxy-operated MANAGED vault for the caller
127
+ * (`POST /nodes/managed`) — the "Create your vault" convenience for
128
+ * non-technical users. Oxy custodial-signs the node registration onto the
129
+ * caller's chain and returns the materialized node (`managed:true,
130
+ * controller:'oxy'`). Idempotent server-side. Auth required; the owner id is
131
+ * resolved from the session, never the body. The node + `/users/me` GET caches
132
+ * are swept on success.
133
+ */
134
+ async provisionManagedVault() {
135
+ try {
136
+ const res = await this.makeRequest('POST', '/nodes/managed', undefined, { cache: false });
137
+ this._sweepNodeCaches();
138
+ return res.node;
139
+ }
140
+ catch (error) {
141
+ throw this.handleError(error);
142
+ }
143
+ }
144
+ /**
145
+ * Send an ingest HINT that a user's node has new records
146
+ * (`POST /nodes/ingest/notify/:userId`). Unauthenticated by design and
147
+ * fire-and-forget on the server (it only schedules a background re-pull of the
148
+ * named user's OWN node, then fully re-verifies — a notify can never inject
149
+ * data), so this resolves once the 202 hint is accepted and returns nothing.
150
+ *
151
+ * @param userId - The user whose node may have new records. URL-encoded.
152
+ */
153
+ async notifyNodeIngest(userId) {
154
+ try {
155
+ await this.makeRequest('POST', `/nodes/ingest/notify/${encodeURIComponent(userId)}`, undefined, { cache: false });
156
+ }
157
+ catch (error) {
158
+ throw this.handleError(error);
159
+ }
160
+ }
161
+ /**
162
+ * Sweep the GET caches a node mutation invalidates: every node read
163
+ * (`GET:/nodes/`) so a re-read reflects the new node / its absence, and
164
+ * `/users/me` because the user's derived DID document embeds an `#oxy-node`
165
+ * service entry that changes on register / revoke / manage. Public rather
166
+ * than `private` because mixins compose into an exported anonymous class
167
+ * where TypeScript cannot represent a private member in the emitted
168
+ * declaration file (TS4094) — mirrors the civic / identity cache sweepers.
169
+ */
170
+ _sweepNodeCaches() {
171
+ this.clearCacheByPrefix(NODES_CACHE_PREFIX);
172
+ this.clearCacheByPrefix(USERS_ME_CACHE_PREFIX);
173
+ }
174
+ };
175
+ }
@@ -593,6 +593,27 @@ function OxyServicesUserMixin(Base) {
593
593
  throw this.handleError(error);
594
594
  }
595
595
  }
596
+ /**
597
+ * Get user mutuals ("followers you know" — users the authenticated viewer
598
+ * follows who also follow `userId`). The viewer is derived server-side from auth.
599
+ */
600
+ async getUserMutuals(userId, pagination) {
601
+ try {
602
+ const params = (0, apiUtils_1.buildPaginationParams)(pagination || {});
603
+ const response = await this.makeRequest('GET', `/users/${userId}/mutuals`, params, {
604
+ cache: true,
605
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
606
+ });
607
+ return {
608
+ mutuals: response.data || [],
609
+ total: response.pagination.total,
610
+ hasMore: response.pagination.hasMore,
611
+ };
612
+ }
613
+ catch (error) {
614
+ throw this.handleError(error);
615
+ }
616
+ }
596
617
  /**
597
618
  * Get notifications
598
619
  */
@@ -34,6 +34,7 @@ const OxyServices_managedAccounts_1 = require("./OxyServices.managedAccounts");
34
34
  const OxyServices_contacts_1 = require("./OxyServices.contacts");
35
35
  const OxyServices_appData_1 = require("./OxyServices.appData");
36
36
  const OxyServices_civic_1 = require("./OxyServices.civic");
37
+ const OxyServices_nodes_1 = require("./OxyServices.nodes");
37
38
  /**
38
39
  * Mixin pipeline - applied in order from first to last.
39
40
  *
@@ -81,6 +82,9 @@ const MIXIN_PIPELINE = [
81
82
  OxyServices_appData_1.OxyServicesAppDataMixin,
82
83
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
83
84
  OxyServices_civic_1.OxyServicesCivicMixin,
85
+ // User nodes / decentralization (Fase 5): register/read/revoke/manage the
86
+ // caller's personal data node + ingest hint.
87
+ OxyServices_nodes_1.OxyServicesNodesMixin,
84
88
  // Utility (last, can use all above)
85
89
  OxyServices_utility_1.OxyServicesUtilityMixin,
86
90
  ];
@@ -49,12 +49,14 @@ exports.ssoGuardKey = ssoGuardKey;
49
49
  exports.ssoDestKey = ssoDestKey;
50
50
  exports.ssoNoSessionKey = ssoNoSessionKey;
51
51
  exports.ssoAttemptedKey = ssoAttemptedKey;
52
+ exports.ssoPriorSessionKey = ssoPriorSessionKey;
52
53
  exports.ssoCallbackBootstrapKey = ssoCallbackBootstrapKey;
53
54
  exports.getSsoCallbackBootstrapScript = getSsoCallbackBootstrapScript;
54
55
  exports.ssoNavigate = ssoNavigate;
55
56
  exports.buildSsoBounceUrl = buildSsoBounceUrl;
56
57
  exports.isCentralIdPOrigin = isCentralIdPOrigin;
57
58
  exports.guardActive = guardActive;
59
+ exports.allowSsoBounce = allowSsoBounce;
58
60
  const authWebUrl_1 = require("./authWebUrl");
59
61
  /**
60
62
  * The RP callback path the central IdP redirects back to after a bounce. The
@@ -78,6 +80,7 @@ const DEST_KEY_PREFIX = 'oxy_sso_dest:';
78
80
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
79
81
  const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
80
82
  const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
83
+ const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
81
84
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
82
85
  function ssoStateKey(origin) {
83
86
  return `${STATE_KEY_PREFIX}${origin}`;
@@ -110,6 +113,23 @@ function ssoNoSessionKey(origin) {
110
113
  function ssoAttemptedKey(origin) {
111
114
  return `${ATTEMPTED_KEY_PREFIX}${origin}`;
112
115
  }
116
+ /**
117
+ * Per-origin DURABLE "this device/origin has had a signed-in Oxy session
118
+ * before" hint.
119
+ *
120
+ * Unlike every other key in this module — which lives in per-tab
121
+ * `sessionStorage` — this hint is written to DURABLE storage (web
122
+ * `localStorage`; the services provider uses its own `storageKeyPrefix`-scoped
123
+ * key in `@oxyhq/services`). It is set whenever a session is established or
124
+ * restored and survives a session expiring; it is cleared ONLY on an explicit
125
+ * full sign-out. It exists purely to drive {@link allowSsoBounce}: a returning
126
+ * visitor (hint present) whose local session has lapsed still gets ONE terminal
127
+ * `/sso` establish bounce to recover a session that lives only at the central
128
+ * IdP, while a truly first-time anonymous visitor is never force-bounced.
129
+ */
130
+ function ssoPriorSessionKey(origin) {
131
+ return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
132
+ }
113
133
  /**
114
134
  * Per-origin marker written by the pre-hydration callback bootstrap.
115
135
  *
@@ -240,3 +260,31 @@ function guardActive(storage, origin, now = Date.now()) {
240
260
  }
241
261
  return now - ts < exports.SSO_GUARD_TTL_MS;
242
262
  }
263
+ /**
264
+ * Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
265
+ * visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).
266
+ *
267
+ * The terminal bounce is the ONLY cold-boot step that can recover a session
268
+ * that lives SOLELY at the central IdP — the cross-apex Relying-Party case
269
+ * (e.g. `mention.earth`, a different apex from `oxy.so`) whose device-local
270
+ * session has expired and whose `Domain=oxy.so` refresh cookie never reaches
271
+ * `api.<apex>`. It is also what plants the first-party per-apex `fedcm_session`
272
+ * cookie that the EARLIER `silent-iframe` step later relies on. So it must fire
273
+ * for a RETURNING user, yet it must NOT force a truly first-time anonymous
274
+ * visitor off to the IdP.
275
+ *
276
+ * - ALLOW when there is a prior-signed-in hint OR a local session was
277
+ * recovered this boot (a returning user) — so a central-only cross-domain
278
+ * session recovers via ONE bounce, after which the per-apex cookie is
279
+ * planted and subsequent loads restore silently with no bounce.
280
+ * - else (no hint, no local session) SUPPRESS — a first-time anonymous
281
+ * visitor browses without a forced redirect.
282
+ *
283
+ * This is the smart DEFAULT and the ONLY behaviour: apps never configure it.
284
+ * It is also the GATE DECISION ONLY — callers still apply the per-tab loop
285
+ * guards (`ssoAttemptedKey`, `ssoNoSessionKey`, {@link guardActive}) so an
286
+ * allowed bounce still fires at most once per cold boot.
287
+ */
288
+ function allowSsoBounce(gate) {
289
+ return gate.hasPriorSession || gate.hasLocalSession;
290
+ }