UNPKG

@safepassage/sdk

Version:

SafePassage SDK - Lightweight redirect-based age verification

1,162 lines (1,152 loc) 40.6 kB
var __defProp = Object.defineProperty; var __defProps = Object.defineProperties; var __getOwnPropDescs = Object.getOwnPropertyDescriptors; var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropSymbols = Object.getOwnPropertySymbols; var __hasOwnProp = Object.prototype.hasOwnProperty; var __propIsEnum = Object.prototype.propertyIsEnumerable; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); if (__getOwnPropSymbols) for (var prop of __getOwnPropSymbols(b)) { if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); } return a; }; var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); var __objRest = (source, exclude) => { var target = {}; for (var prop in source) if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0) target[prop] = source[prop]; if (source != null && __getOwnPropSymbols) for (var prop of __getOwnPropSymbols(source)) { if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop)) target[prop] = source[prop]; } return target; }; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src-redirect/utils/security.ts function isOriginTrusted(origin, trustedOrigins) { return trustedOrigins.includes(origin); } function validatePostMessageOrigin(event, trustedOrigins, allowedCustomOrigins = [], logLabel = "SDK") { var _a; const { origin } = event; if (isOriginTrusted(origin, trustedOrigins)) { return true; } if (allowedCustomOrigins.length > 0) { const isCustomOriginAllowed = allowedCustomOrigins.some((allowedOrigin) => { if (allowedOrigin.startsWith("*.")) { const domain = allowedOrigin.slice(2); return origin.endsWith(`.${domain}`) || origin === `https://${domain}` || origin === `http://${domain}`; } return origin === allowedOrigin; }); if (isCustomOriginAllowed) { return true; } } console.warn( `${logLabel} Security: Blocked PostMessage from untrusted origin: ${origin}`, { trustedOrigins, allowedCustomOrigins, eventType: (_a = event.data) == null ? void 0 : _a.type } ); return false; } function validateVerificationMessage(event, expectedSessionId, expectedMessageType, legacyMessageType) { const { data } = event; if (!data || typeof data !== "object") { return { isValid: false, error: "Invalid message format" }; } const allowedTypes = legacyMessageType ? [expectedMessageType, legacyMessageType] : [expectedMessageType]; if (!allowedTypes.includes(data.type)) { return { isValid: false, error: "Invalid message type" }; } if (!data.sessionId || data.sessionId !== expectedSessionId) { return { isValid: false, error: "Session ID mismatch" }; } if (!data.status || !["verified", "failed", "cancelled"].includes(data.status)) { return { isValid: false, error: "Invalid status value" }; } return { isValid: true }; } function enforceHTTPS(environment, logLabel = "SDK") { if (environment === "production" && window.location.protocol !== "https:") { console.warn( `${logLabel} Warning: HTTPS recommended for production environment`, { current: window.location.href } ); } } function validateReturnUrl(url, environment, _logLabel = "SDK") { try { const parsed = new URL(url); if (parsed.protocol === "file:") { return { isValid: false, error: "file:// URLs are not supported. The verification redirect cannot return to local files. Please use a local web server (e.g., npx serve . or python3 -m http.server) instead of opening the HTML file directly." }; } if (parsed.protocol !== "https:") { const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; if (!isLocalhost) { return { isValid: false, error: `HTTPS required for return URLs in ${environment}` }; } } const suspiciousPatterns = [ /data:/i, /javascript:/i, /vbscript:/i, /file:/i, /ftp:/i ]; for (const pattern of suspiciousPatterns) { if (pattern.test(url)) { return { isValid: false, error: "Blocked suspicious URL scheme" }; } } return { isValid: true }; } catch (e) { return { isValid: false, error: "Invalid URL format" }; } } function logSecurityEvent(event, metadata, logLabel = "SDK") { const context = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown", url: typeof window !== "undefined" ? window.location.href : "unknown" }; console.warn(`${logLabel} Security Event: ${event}`, __spreadValues(__spreadValues({}, context), metadata)); } var VerificationRateLimit, verificationRateLimit; var init_security = __esm({ "src-redirect/utils/security.ts"() { "use strict"; VerificationRateLimit = class { constructor() { this.attempts = /* @__PURE__ */ new Map(); this.maxAttempts = 5; this.timeWindow = 6e4; } // 1 minute isAllowed(identifier, logLabel = "SDK") { const now = Date.now(); const attempts = this.attempts.get(identifier) || []; const recentAttempts = attempts.filter( (time) => now - time < this.timeWindow ); if (recentAttempts.length >= this.maxAttempts) { console.warn( `${logLabel} Security: Rate limit exceeded for ${identifier}` ); return false; } recentAttempts.push(now); this.attempts.set(identifier, recentAttempts); return true; } reset(identifier) { this.attempts.delete(identifier); } }; verificationRateLimit = new VerificationRateLimit(); } }); // src-redirect/utils/crypto.ts var crypto_exports = {}; __export(crypto_exports, { createSignedState: () => createSignedState, generateHMAC: () => generateHMAC, generateSecureToken: () => generateSecureToken, parseSignedState: () => parseSignedState, verifyHMAC: () => verifyHMAC }); async function generateHMAC(data, secret) { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const dataBuffer = encoder.encode(data); const key = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const signature = await crypto.subtle.sign("HMAC", key, dataBuffer); return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join(""); } async function verifyHMAC(data, signature, secret) { try { const expectedSignature = await generateHMAC(data, secret); return constantTimeCompare(signature, expectedSignature); } catch (e) { return false; } } function constantTimeCompare(a, b) { if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } function generateSecureToken(length = 32) { const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); } async function createSignedState(payload, hmacSecret) { const timestampedPayload = __spreadProps(__spreadValues({}, payload), { timestamp: Date.now(), nonce: generateSecureToken(16) }); const dataString = JSON.stringify(timestampedPayload); const signature = await generateHMAC(dataString, hmacSecret); const signedPayload = { data: timestampedPayload, signature }; return btoa(JSON.stringify(signedPayload)); } async function parseSignedState(signedState, hmacSecret, maxAge = STATE_EXPIRY_MS, logLabel = "SDK") { try { const json = atob(signedState); const signedPayload = JSON.parse(json); if (!signedPayload.data || !signedPayload.signature) { console.warn(`${logLabel}: Invalid signed state format`); return null; } const { data, signature } = signedPayload; const dataString = JSON.stringify(data); const isValid = await verifyHMAC(dataString, signature, hmacSecret); if (!isValid) { console.warn(`${logLabel}: State signature verification failed`); return null; } if (data.timestamp) { const age = Date.now() - data.timestamp; if (age > maxAge) { console.warn(`${logLabel}: State parameter expired`, { age, maxAge }); return null; } } const _a = data, { timestamp, nonce } = _a, payload = __objRest(_a, ["timestamp", "nonce"]); void timestamp; void nonce; return payload; } catch (error) { console.warn(`${logLabel}: Failed to parse signed state`, error); return null; } } var init_crypto = __esm({ "src-redirect/utils/crypto.ts"() { "use strict"; init_validation(); } }); // src-redirect/utils/validation.ts function validateConfig(config, context) { if (!config.apiKey) { throw new Error("apiKey is required"); } if (config.apiKey.length > MAX_API_KEY_LENGTH) { throw new Error( `apiKey exceeds maximum length of ${MAX_API_KEY_LENGTH} characters` ); } if (!API_KEY_PATTERN.test(config.apiKey)) { throw new Error( "Invalid apiKey format. Expected pk_xxx (public) or sk_xxx (private)" ); } if (typeof window !== "undefined" && config.apiKey.startsWith("sk_")) { throw new Error( `Secret keys (sk_) should never be used in browser code for security reasons. Secret keys expose your account to unauthorized access if used client-side. Please use your public key (pk_) instead. If you need to use features that require a secret key (like custom challenge age), create the session server-side and pass the sessionId to startVerificationWithSession(). See: ${context.docsUrl}/server-side-sessions` ); } if (!config.returnUrl) { throw new Error("returnUrl is required"); } if (config.returnUrl.length > MAX_URL_LENGTH) { throw new Error( `returnUrl exceeds maximum length of ${MAX_URL_LENGTH} characters` ); } const environment = detectEnvironment(); const returnUrlValidation = validateReturnUrl(config.returnUrl, environment, context.brandName); if (!returnUrlValidation.isValid) { throw new Error( `returnUrl validation failed: ${returnUrlValidation.error}` ); } if (config.cancelUrl) { if (config.cancelUrl.length > MAX_URL_LENGTH) { throw new Error( `cancelUrl exceeds maximum length of ${MAX_URL_LENGTH} characters` ); } const cancelUrlValidation = validateReturnUrl(config.cancelUrl, environment, context.brandName); if (!cancelUrlValidation.isValid) { throw new Error( `cancelUrl validation failed: ${cancelUrlValidation.error}` ); } } if (config.defaultChallengeAge !== void 0) { if (config.defaultChallengeAge < MINIMUM_AGE) { throw new Error(`defaultChallengeAge must be at least ${MINIMUM_AGE}`); } if (config.defaultChallengeAge > MAXIMUM_AGE) { throw new Error(`defaultChallengeAge cannot exceed ${MAXIMUM_AGE}`); } } if (config.defaultVerificationMode && !["L1", "L2"].includes(config.defaultVerificationMode)) { throw new Error("defaultVerificationMode must be L1 or L2"); } if (config.mode && !["redirect", "new-tab"].includes(config.mode)) { throw new Error("mode must be redirect or new-tab"); } if (config.newTabTarget && !["popup", "tab"].includes(config.newTabTarget)) { throw new Error("newTabTarget must be popup or tab"); } } function detectEnvironment() { if (typeof window === "undefined") { return "production"; } const hostname = window.location.hostname; if (hostname.includes("staging") || hostname.includes("stage")) { return "staging"; } return "production"; } async function generateState(payload, environment, hmacSecret, _logLabel = "SDK") { const { createSignedState: createSignedState2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports)); return createSignedState2(payload, hmacSecret); } var MINIMUM_AGE, MAXIMUM_AGE, MAX_URL_LENGTH, MAX_API_KEY_LENGTH, STATE_EXPIRY_MS, API_KEY_PATTERN; var init_validation = __esm({ "src-redirect/utils/validation.ts"() { "use strict"; init_security(); MINIMUM_AGE = 25; MAXIMUM_AGE = 150; MAX_URL_LENGTH = 2048; MAX_API_KEY_LENGTH = 128; STATE_EXPIRY_MS = 6e5; API_KEY_PATTERN = /^(pk_|sk_)[a-zA-Z0-9_]+$/; } }); // src-redirect/utils/polyfills.ts function setupPolyfills() { if (!crypto.randomUUID) { crypto.randomUUID = function() { const array = new Uint8Array(16); crypto.getRandomValues(array); array[6] = array[6] & 15 | 64; array[8] = array[8] & 63 | 128; const hex = Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join(""); return [ hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32) ].join("-"); }; } } function checkBrowserCompatibility(logLabel = "SDK") { const warnings = []; if (!window.crypto || !window.crypto.getRandomValues) { throw new Error(`${logLabel} requires Web Crypto API support`); } if (!window.crypto.subtle) { throw new Error( `${logLabel} requires Web Crypto subtle API for HMAC operations` ); } if (!crypto.randomUUID) { warnings.push("crypto.randomUUID not supported, using polyfill"); } if (!window.URLSearchParams) { warnings.push( "URLSearchParams not supported, consider adding a polyfill for IE 11 support" ); } if (warnings.length > 0) { console.warn(`${logLabel} Browser Compatibility:`, warnings.join("; ")); } } // src-redirect/core/VerificationSDK.ts init_validation(); // src-redirect/utils/environment.ts function getEnvironmentUrl(environment, urls) { const url = urls.verifyUiUrl; if (!url || !url.startsWith("https://")) { throw new Error(`HTTPS required for ${environment} environment`); } return url; } function getApiUrl(environment, urls) { const url = urls.apiUrl; if (!url || !url.startsWith("https://")) { throw new Error( `HTTPS required for API URLs in ${environment} environment` ); } return url; } function validateEnvironmentSecurity(environment, urls, logLabel = "SDK") { const isSecure = window.location.protocol === "https:"; switch (environment) { case "production": if (!isSecure) { console.warn(`${logLabel} Warning: HTTPS recommended for production environment`); } break; case "staging": if (!isSecure) { console.warn( `${logLabel} Warning: HTTPS strongly recommended in staging environment` ); } break; } try { getEnvironmentUrl(environment, urls); getApiUrl(environment, urls); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Environment configuration validation failed: ${errorMessage}`); } } // src-redirect/core/VerificationSDK.ts init_security(); var _VerificationSDK = class _VerificationSDK { /** * Initialize SDK * * Validates configuration, sets up security measures, and prepares the SDK * for verification operations. Performs comprehensive environment validation * and security initialization. */ constructor(config, brandUrls, brandConstants) { this.popupWindow = null; this.messageListener = null; this.popupMonitorInterval = null; this.unloadListener = null; this.isVerificationInProgress = false; this.currentSessionId = null; this.hasReceivedResult = false; // Server-provided verify URL (includes sessionToken) this.lastVerifyUrl = null; // Server-provided session token (WS auth) this.lastSessionToken = null; // External user ID provided during verify() for cancellation redirects this.lastExternalUserId = null; // Sandbox mode flag from session creation (for UI labeling) this.lastSandboxMode = null; // Temporary storage for QR handoff token to include in state this.temporaryHandoffToken = null; this.brandUrls = brandUrls; this.brandConstants = brandConstants; validateConfig(config, { brandName: this.brandConstants.name, docsUrl: this.brandConstants.docsUrl }); let normalizedEnvironment = config.environment || this.detectEnvironment(); if (normalizedEnvironment !== "staging" && normalizedEnvironment !== "production") { console.warn( `${this.brandConstants.name} SDK: Unknown environment '${normalizedEnvironment}', defaulting to 'production'` ); normalizedEnvironment = "production"; } this.config = __spreadProps(__spreadValues({}, config), { environment: normalizedEnvironment, mode: config.mode || "redirect", newTabTarget: config.newTabTarget || "popup" }); validateEnvironmentSecurity(this.config.environment, this.getUrlConfig(), this.brandConstants.name); enforceHTTPS(this.config.environment, this.brandConstants.name); logSecurityEvent("SDK_INITIALIZED", { environment: this.config.environment, mode: this.config.mode, origin: window.location.origin, protocol: window.location.protocol, hostname: window.location.hostname }, this.brandConstants.name); this.setupAutoCleanup(); } /** * Initiate verification with race condition protection */ async verify(options = {}) { var _a, _b, _c, _d, _e, _f; const isPublicKey = this.isPublicKey(); let sessionId; if (isPublicKey) { sessionId = await this.createInternalSession(options); } else { throw new Error( "Private API keys (sk_) should use the direct API, not the SDK. The SDK is designed for browser-based public key usage only." ); } if (!sessionId) { throw new Error("Failed to obtain sessionId from server"); } if (this.isVerificationInProgress) { const error = new Error( `Verification already in progress for session ${(_a = this.currentSessionId) == null ? void 0 : _a.substring(0, 8)}...` ); logSecurityEvent("RACE_CONDITION_PREVENTED", { currentSession: ((_b = this.currentSessionId) == null ? void 0 : _b.substring(0, 8)) + "...", attemptedSession: "new-session-attempt", origin: window.location.origin }, this.brandConstants.name); (_d = (_c = this.config).onError) == null ? void 0 : _d.call(_c, error); throw error; } this.isVerificationInProgress = true; this.currentSessionId = sessionId; this.lastExternalUserId = options.externalUserId || null; try { const rateLimitKey = `${this.config.apiKey}:${window.location.origin}`; if (!verificationRateLimit.isAllowed(rateLimitKey, this.brandConstants.name)) { const error = new Error( "Too many verification attempts. Please wait before trying again." ); logSecurityEvent("RATE_LIMIT_EXCEEDED", { apiKey: this.config.apiKey.substring(0, 8) + "...", origin: window.location.origin, sessionId: sessionId ? sessionId.substring(0, 8) + "..." : "undefined" }, this.brandConstants.name); (_f = (_e = this.config).onError) == null ? void 0 : _f.call(_e, error); throw error; } const verificationUrl = await this.buildVerificationUrl( options, sessionId ); logSecurityEvent("VERIFICATION_INITIATED", { environment: this.config.environment, mode: this.config.mode, sessionId: sessionId ? sessionId.substring(0, 8) + "..." : "undefined", origin: window.location.origin }, this.brandConstants.name); if (this.config.mode === "new-tab") { this.openNewTab(verificationUrl, sessionId); } else { this.unlockVerification(); this.redirect(verificationUrl); } } catch (error) { this.unlockVerification(); throw error; } } /** * Build verification URL with HMAC-signed state */ async buildVerificationUrl(options, sessionId) { var _a; const baseUrl = this.config.verifyUrl || getEnvironmentUrl(this.config.environment, this.getUrlConfig()); const hasExplicitChallengeAge = options.challengeAge !== void 0; const hasExplicitVerificationMode = options.verificationMode !== void 0; const hasOverrides = hasExplicitChallengeAge || hasExplicitVerificationMode; const state = await generateState( { merchantId: this.config.apiKey, sessionId, returnUrl: this.config.returnUrl, cancelUrl: this.config.cancelUrl, challengeAge: options.challengeAge || this.config.defaultChallengeAge, verificationMode: options.verificationMode || this.config.defaultVerificationMode, hasOverrides, // Flag to indicate explicit overrides externalUserId: options.externalUserId, timestamp: Date.now(), // Additional config for self-contained verify-ui apiUrl: this.getPortalApiUrl(), engineUrl: this.getEngineUrl(), wsUrl: this.getWebSocketUrl(), environment: this.config.environment, features: { testMode: false, warmupPeriodMs: 500, qualityThreshold: 0.6, sandboxMode: (_a = this.lastSandboxMode) != null ? _a : false }, // Include handoffToken if available (for QR code desktop flow) handoffToken: this.temporaryHandoffToken || void 0, // Include sessionToken and verifyUrl to make UI auth deterministic sessionToken: this.lastSessionToken || void 0, verifyUrl: this.lastVerifyUrl || void 0 }, this.config.environment, this.getHmacSecret(), this.brandConstants.name ); const language = options.language || this.config.language; if (this.lastVerifyUrl) { try { const resolvedVerifyUrl = this.applyLocalVerifyOverride(this.lastVerifyUrl); const url = new URL(resolvedVerifyUrl); url.searchParams.set("state", state); url.searchParams.set("mode", this.config.mode); if (options.skipIntro) { url.searchParams.set("skip_intro", "true"); } if (options.autoReturn) { url.searchParams.set("auto_return", "true"); } if (language) { url.searchParams.set("lang", language); } return url.toString(); } catch (e) { } } const params = new URLSearchParams({ state, sessionId, mode: this.config.mode }); if (options.skipIntro) { params.set("skip_intro", "true"); } if (options.autoReturn) { params.set("auto_return", "true"); } if (language) { params.set("lang", language); } return `${baseUrl}/?${params.toString()}`; } /** * Redirect in same tab */ redirect(url) { window.location.href = url; } /** * Open in new tab with PostMessage communication and proper cleanup */ openNewTab(url, sessionId) { var _a, _b; this.cleanup(); this.hasReceivedResult = false; if (this.popupMonitorInterval) { clearInterval(this.popupMonitorInterval); this.popupMonitorInterval = null; } const target = this.config.newTabTarget || "popup"; if (target === "tab") { this.popupWindow = window.open(url, "_blank"); } else { this.popupWindow = window.open( url, this.brandConstants.popupName, "width=600,height=700" ); } if (!this.popupWindow) { (_b = (_a = this.config).onError) == null ? void 0 : _b.call( _a, new Error( "Failed to open verification window. Please check popup blocker settings." ) ); return; } const trustedOrigins = this.getTrustedOrigins(); const allowedCustomOrigins = this.getAllowedCustomOrigins(url); const expectedMessageType = this.brandConstants.messageType; const legacyMessageType = this.brandConstants.legacyMessageType; this.messageListener = (event) => { var _a2, _b2, _c, _d, _e, _f, _g; const messageType = (_a2 = event.data) == null ? void 0 : _a2.type; const allowedTypes = legacyMessageType ? [expectedMessageType, legacyMessageType] : [expectedMessageType]; if (!messageType || typeof messageType !== "string" || !allowedTypes.includes(messageType)) { return; } if (!validatePostMessageOrigin(event, trustedOrigins, allowedCustomOrigins, this.brandConstants.name)) { logSecurityEvent("POSTMESSAGE_ORIGIN_BLOCKED", { origin: event.origin, environment: this.config.environment, expectedOrigins: `${this.brandConstants.name} trusted origins for ${this.config.environment}`, messageType: (_b2 = event.data) == null ? void 0 : _b2.type }, this.brandConstants.name); return; } const messageValidation = validateVerificationMessage( event, sessionId, expectedMessageType, legacyMessageType ); if (!messageValidation.isValid) { logSecurityEvent("POSTMESSAGE_VALIDATION_FAILED", { error: messageValidation.error, origin: event.origin, sessionId: sessionId.substring(0, 8) + "...", messageType: (_c = event.data) == null ? void 0 : _c.type }, this.brandConstants.name); return; } const status = event.data.status; if (status === "cancelled") { this.handleCancellation(sessionId, "postmessage"); return; } const result = { sessionId: event.data.sessionId, status, timestamp: event.data.timestamp, externalUserId: event.data.externalUserId }; this.hasReceivedResult = true; logSecurityEvent("VERIFICATION_COMPLETED", { status: result.status, sessionId: sessionId.substring(0, 8) + "...", origin: event.origin }, this.brandConstants.name); this.cleanup({ closePopup: false }); this.unlockVerification(); if (this.popupMonitorInterval) { clearInterval(this.popupMonitorInterval); this.popupMonitorInterval = null; } if (result.status === "verified") { (_e = (_d = this.config).onComplete) == null ? void 0 : _e.call(_d, result); } else { (_g = (_f = this.config).onError) == null ? void 0 : _g.call( _f, new Error(`Verification failed: ${result.status}`) ); } }; window.addEventListener("message", this.messageListener); this.popupMonitorInterval = setInterval(() => { if (this.popupWindow && this.popupWindow.closed) { logSecurityEvent("POPUP_CLOSED_BY_USER", { sessionId: sessionId.substring(0, 8) + "...", environment: this.config.environment }, this.brandConstants.name); if (!this.hasReceivedResult) { this.handleCancellation(sessionId, "popup-closed"); } } }, 500); } /** * Set up automatic cleanup on page unload to prevent memory leaks */ setupAutoCleanup() { this.unloadListener = () => { logSecurityEvent("SDK_AUTO_CLEANUP", { environment: this.config.environment, trigger: "page_unload" }, this.brandConstants.name); this.cleanup(); this.unlockVerification(); }; window.addEventListener("beforeunload", this.unloadListener); window.addEventListener("pagehide", this.unloadListener); if (window.history && window.history.pushState) { const originalPushState = window.history.pushState; window.history.pushState = (...args) => { this.cleanup(); this.unlockVerification(); return originalPushState.apply(window.history, args); }; } } /** * Auto-detect environment based on current URL */ detectEnvironment() { const hostname = window.location.hostname; if (hostname.includes("staging") || hostname.includes("stage")) { return "staging"; } return "production"; } /** * Get the current environment */ getEnvironment() { return this.config.environment; } /** * Unlock verification process to allow new verifications */ unlockVerification() { this.isVerificationInProgress = false; this.currentSessionId = null; logSecurityEvent("VERIFICATION_UNLOCKED", { environment: this.config.environment, origin: window.location.origin }, this.brandConstants.name); } /** * Internal cleanup method to prevent memory leaks */ cleanup(options = {}) { const shouldClosePopup = options.closePopup !== false; if (this.popupWindow) { if (shouldClosePopup && !this.popupWindow.closed) { this.popupWindow.close(); } if (shouldClosePopup || this.popupWindow.closed) { this.popupWindow = null; } } if (this.messageListener) { window.removeEventListener("message", this.messageListener); this.messageListener = null; } if (this.popupMonitorInterval) { clearInterval(this.popupMonitorInterval); this.popupMonitorInterval = null; } } handleCancellation(sessionId, source) { if (this.hasReceivedResult) { return; } this.hasReceivedResult = true; logSecurityEvent("VERIFICATION_CANCELLED", { source, sessionId: sessionId.substring(0, 8) + "...", environment: this.config.environment, origin: window.location.origin }, this.brandConstants.name); this.cleanup(); this.unlockVerification(); let shouldRedirect = true; if (this.config.onCancel) { try { const result = this.config.onCancel(); if (result === false) { shouldRedirect = false; } } catch (error) { logSecurityEvent("CANCEL_CALLBACK_FAILED", { error: error instanceof Error ? error.message : String(error), sessionId: sessionId.substring(0, 8) + "..." }, this.brandConstants.name); } } if (shouldRedirect) { this.redirectToCancelUrl(sessionId); } } redirectToCancelUrl(sessionId) { if (!this.config.cancelUrl) { return; } try { const decodedUrl = decodeURIComponent(this.config.cancelUrl); const redirectUrl = new URL(decodedUrl); redirectUrl.searchParams.set("sessionId", sessionId); redirectUrl.searchParams.set("status", "cancelled"); redirectUrl.searchParams.set("timestamp", Date.now().toString()); if (this.lastExternalUserId) { redirectUrl.searchParams.set("externalUserId", this.lastExternalUserId); } window.location.href = redirectUrl.toString(); } catch (error) { logSecurityEvent("CANCEL_REDIRECT_FAILED", { error: error instanceof Error ? error.message : String(error), sessionId: sessionId.substring(0, 8) + "..." }, this.brandConstants.name); } } /** * Remove auto-cleanup listeners */ removeAutoCleanupListeners() { if (this.unloadListener) { window.removeEventListener("beforeunload", this.unloadListener); window.removeEventListener("pagehide", this.unloadListener); this.unloadListener = null; } } /** * Public cleanup method for manual resource management */ destroy() { logSecurityEvent("SDK_DESTROYED", { environment: this.config.environment, origin: window.location.origin }, this.brandConstants.name); this.cleanup(); this.unlockVerification(); this.removeAutoCleanupListeners(); } /** * Get Portal API URL based on environment and brand */ getPortalApiUrl() { if (this.config.apiUrl) { return this.config.apiUrl; } return this.getUrlConfig().apiUrl; } /** * Get Engine URL based on environment and brand */ getEngineUrl() { return this.getUrlConfig().engineUrl; } /** * Get WebSocket URL based on environment and brand */ getWebSocketUrl() { return this.getUrlConfig().wsUrl; } /** * Detect if this is a public key (pk_ prefix) vs private key (sk_ prefix) */ isPublicKey() { return this.config.apiKey.startsWith("pk_"); } /** * Create session internally for public keys */ async createInternalSession(options) { var _a, _b; try { const portalApiUrl = this.getPortalApiUrl(); const response = await fetch(`${portalApiUrl}/api/v1/sessions/create`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.apiKey}` }, body: JSON.stringify({ merchantId: this.config.apiKey, returnUrl: this.config.returnUrl, cancelUrl: this.config.cancelUrl, challengeAge: options.challengeAge, verificationMode: options.verificationMode, merchantName: document.title || window.location.hostname, externalUserId: options.externalUserId }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorCode = errorData == null ? void 0 : errorData.code; if (this.isBillingBlockError(errorCode)) { const language = options.language || this.config.language; this.openBillingBlockPage(errorCode, errorData == null ? void 0 : errorData.portalUrl, language); } throw new Error( `Failed to create session: ${response.status} ${response.statusText}. ${errorData.message || ""}` ); } const sessionData = await response.json(); const sessionId = sessionData.sessionId; if (!sessionId) { throw new Error("Server did not return a sessionId"); } if (sessionData.verifyUrl) { this.lastVerifyUrl = sessionData.verifyUrl; } if (sessionData.sessionToken) { this.lastSessionToken = sessionData.sessionToken; } if (sessionData.handoffToken) { this.temporaryHandoffToken = sessionData.handoffToken; } if (typeof sessionData.sandboxMode === "boolean") { this.lastSandboxMode = sessionData.sandboxMode; } else { this.lastSandboxMode = null; } logSecurityEvent("INTERNAL_SESSION_CREATED", { sessionId: sessionId.substring(0, 8) + "...", environment: this.config.environment, apiKeyType: "public" }, this.brandConstants.name); return sessionId; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logSecurityEvent("INTERNAL_SESSION_FAILED", { error: errorMessage, environment: this.config.environment, apiKeyType: "public" }, this.brandConstants.name); (_b = (_a = this.config).onError) == null ? void 0 : _b.call(_a, error); throw new Error(`Failed to create verification session: ${errorMessage}`); } } isBillingBlockError(code) { return code === "SUBSCRIPTION_REQUIRED" || code === "PLAN_LIMIT_REACHED" || code === "SANDBOX_LIMIT_REACHED"; } openBillingBlockPage(code, portalUrl, language) { var _a, _b, _c, _d; try { const baseUrl = this.config.verifyUrl || getEnvironmentUrl(this.config.environment, this.getUrlConfig()); const resolvedUrl = this.applyLocalVerifyOverride(baseUrl); const url = new URL(resolvedUrl); url.searchParams.set("blocked", code); if (portalUrl) { url.searchParams.set("portalUrl", portalUrl); } const effectiveLanguage = language || this.config.language; if (effectiveLanguage) { url.searchParams.set("lang", effectiveLanguage); } if (this.config.mode === "new-tab") { const target = this.config.newTabTarget || "popup"; const popup = target === "tab" ? window.open(url.toString(), "_blank") : window.open( url.toString(), this.brandConstants.popupName, "width=600,height=700" ); if (!popup) { (_b = (_a = this.config).onError) == null ? void 0 : _b.call( _a, new Error( "Failed to open billing notice window. Please check popup blocker settings." ) ); } return; } this.redirect(url.toString()); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); (_d = (_c = this.config).onError) == null ? void 0 : _d.call( _c, new Error(`Failed to open billing notice: ${errorMessage}`) ); } } getUrlConfig() { return this.brandUrls[this.config.environment] || this.brandUrls.production; } getTrustedOrigins() { return this.getUrlConfig().trustedOrigins; } getAllowedCustomOrigins(verificationUrl) { const origins = /* @__PURE__ */ new Set(); const overrideOrigin = this.getLocalOrigin(this.config.verifyUrl || null); const verificationOrigin = this.getLocalOrigin(verificationUrl || null); if (overrideOrigin) { origins.add(overrideOrigin); } if (verificationOrigin) { origins.add(verificationOrigin); } return Array.from(origins); } getLocalOrigin(urlValue) { if (!urlValue) { return null; } try { const parsed = new URL(urlValue); if (_VerificationSDK.LOCAL_HOSTNAMES.has(parsed.hostname)) { return parsed.origin; } } catch (e) { return null; } return null; } applyLocalVerifyOverride(rawUrl) { const overrideOrigin = this.getLocalOrigin(this.config.verifyUrl || null); if (!overrideOrigin) { return rawUrl; } try { const overrideUrl = new URL(overrideOrigin); const targetUrl = new URL(rawUrl); targetUrl.protocol = overrideUrl.protocol; targetUrl.host = overrideUrl.host; return targetUrl.toString(); } catch (e) { return rawUrl; } } getHmacSecret() { return this.config.environment === "staging" ? this.brandConstants.hmacSecretStaging : this.brandConstants.hmacSecretProd; } }; // Local override hostnames allowed for internal testing _VerificationSDK.LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]); var VerificationSDK = _VerificationSDK; // src-redirect/brands/safepassage/urls.ts var BRAND_URLS = { production: { apiUrl: "https://api.safepassageapp.com", verifyUiUrl: "https://av.safepassageapp.com", engineUrl: "https://engine.safepassageapp.com", wsUrl: "wss://engine.safepassageapp.com/api/websocket/stream", trustedOrigins: [ "https://av.safepassageapp.com", "https://portal.safepassageapp.com", "https://api.safepassageapp.com" ] }, staging: { apiUrl: "https://api.verityav-staging-usw1a.safepassageapp.com", verifyUiUrl: "https://av.verityav-staging-usw1a.safepassageapp.com", engineUrl: "https://engine.verityav-staging-usw1a.safepassageapp.com", wsUrl: "wss://engine.verityav-staging-usw1a.safepassageapp.com/api/websocket/stream", trustedOrigins: [ "https://av.verityav-staging-usw1a.safepassageapp.com", "https://portal.verityav-staging-usw1a.safepassageapp.com", "https://api.verityav-staging-usw1a.safepassageapp.com" ] } }; var BRAND_CONSTANTS = { name: "SafePassage", hmacSecretProd: "safepassage-prod-hmac-2025", hmacSecretStaging: "safepassage-stage-hmac-2025", messageType: "safepassage:verification:complete", legacyMessageType: "safepassage-verification", popupName: "safepassage-verify", docsUrl: "https://docs.safepassageapp.com" }; // src-redirect/brands/safepassage/index.ts var SafePassage = class extends VerificationSDK { constructor(config) { super(config, BRAND_URLS, BRAND_CONSTANTS); } }; var VERSION = "3.5.2"; SafePassage.VERSION = VERSION; if (typeof window !== "undefined") { setupPolyfills(); checkBrowserCompatibility(`${BRAND_CONSTANTS.name} SDK`); } var index_default = SafePassage; export { SafePassage, VERSION, index_default as default };