(function (root, factory) { if (typeof module !== 'undefined' && module.exports) { module.exports = options => factory({ ...options, privateDatabases: require('./private-data-registry.js'), }); } else { const base = new URL('./', root.location.href).pathname; const originalFetch = root.fetch.bind(root); const boundary = factory({ cookie: () => root.document.cookie, origin: root.location.origin, base, fetchImpl: originalFetch, localStorage: root.localStorage, sessionStorage: root.sessionStorage, indexedDB: root.indexedDB, caches: root.caches, serviceWorker: root.navigator?.serviceWorker, credentials: root.navigator?.credentials, MessageChannel: root.MessageChannel, privateDatabases: root.stackchainPrivateDatabases, location: root.location, addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options), confirmAction: message => root.confirm(message), promptAuthorization: details => root.prompt( `Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`, ), onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), onClearError: error => { root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message })); root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.'); }, }); root.fetch = boundary.fetch; const attach = () => { boundary.startActivityHeartbeat(); boundary.startReconnectResume(); const button = root.document.getElementById('sign-out'); if (button) button.addEventListener('click', () => boundary.signOut()); const allDevicesButton = root.document.getElementById('sign-out-all'); if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices()); const devicesButton = root.document.getElementById('active-devices'); const devicesSheet = root.document.getElementById('active-devices-sheet'); const devicesStatus = root.document.getElementById('active-devices-status'); const closeDevices = root.document.getElementById('close-active-devices'); const returnFromSecurity = root.document.getElementById('return-from-security-center'); const beginSecurityDetour = () => { if (root.matchMedia?.('(max-width: 600px)')?.matches) { root.stackchainTodayTimerView?.beginDetour?.('security-center'); } }; const securityFeatures = root.createFeatureLoader({ document: root.document, urls: { 'security-center': root.document.querySelector( 'meta[name="stackchain-feature-security-center"]' )?.content || '', }, }); let loadingSecurityCenter = false; const openSecurityCenter = async () => { if (loadingSecurityCenter) return; loadingSecurityCenter = true; beginSecurityDetour(); devicesSheet.hidden = false; closeDevices?.focus(); try { await securityFeatures.run('security-center', { trigger: devicesButton, status: devicesStatus, retryLabel: 'Tap Active devices to retry.', }, () => { devicesButton.removeEventListener('click', openSecurityCenter); root.attachSecurityCenter(boundary, { onOpen: beginSecurityDetour, onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'), }).open(); }); } finally { loadingSecurityCenter = false; } }; if (devicesButton && devicesSheet) { devicesButton.addEventListener('click', openSecurityCenter); } if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { devicesSheet.hidden = true; devicesButton?.focus(); root.stackchainTodayTimerView?.finishDetour?.('security-center'); }); if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => { if (root.history?.state?.stackchainSecuritySection) root.history.back(); else devicesSheet.hidden = true; }); boundary.refreshOfflineLease().then(valid => { if (valid) boundary.resumeQueuedWork(); }); }; root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event)); if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); else attach(); root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener, promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, requestTimeoutMs = 15000, activityHeartbeatIntervalMs = 60000, now = () => Date.now(), setTimer = (callback, delay) => setTimeout(callback, delay), }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; let idleLockStarted = false; let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY; let activityHeartbeat = null; async function handleRemoteRevocation() { if (expirationStarted) return; expirationStarted = true; await clearPrivateDeviceData(); location.replace(base + 'login?reason=session-revoked'); } async function handleSessionExpiry() { if (expirationStarted) return; expirationStarted = true; await clearPrivateDeviceData(); onExpired(); location.replace(base + 'login?reason=session-expired'); } function handleSessionIdle() { if (expirationStarted || idleLockStarted) return; idleLockStarted = true; location.replace(base + 'login?reason=session-idle'); } async function handleServiceWorkerMessage(event) { if (event?.data?.type === 'stackchain-session-revoked') { await handleRemoteRevocation(); } if (event?.data?.type === 'stackchain-session-idle') { handleSessionIdle(); } if (event?.data?.type === 'stackchain-session-expired') { await handleSessionExpiry(); } } function storedOfflineLease() { const value = Number(localStorage?.getItem?.('stackchain.session-expires-at')); return Number.isInteger(value) && value > 0 ? value : 0; } function storedOfflineIdleLease() { const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at')); return Number.isInteger(value) && value > 0 ? value : 0; } function scheduleOfflineExpiry(expiresAt) { const delay = Math.max(0, expiresAt * 1000 - now()); setTimer(() => handleSessionExpiry(), delay); } function scheduleOfflineIdle(idleExpiresAt) { const delay = Math.max(0, idleExpiresAt * 1000 - now()); setTimer(() => { if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle(); }, delay); } async function publishOfflineLease(expiresAt, idleExpiresAt) { localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt)); localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt)); scheduleOfflineExpiry(expiresAt); scheduleOfflineIdle(idleExpiresAt); const registration = await serviceWorker?.ready; registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt, idleExpiresAt, }); } function csrfToken() { const entry = String(cookie?.() || '').split(';') .map(value => value.trim()) .find(value => value.startsWith('stackchain_csrf=')); return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : ''; } function decodeBase64Url(value) { const padded = String(value).replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - String(value).length % 4) % 4); return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); } function encodeBase64Url(value) { if (value === null || value === undefined) return null; let binary = ''; new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); }); return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); } function authenticationCredentialJSON(credential) { return { id: credential.id, type: credential.type, rawId: encodeBase64Url(credential.rawId), response: { authenticatorData: encodeBase64Url(credential.response.authenticatorData), clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), signature: encodeBase64Url(credential.response.signature), userHandle: encodeBase64Url(credential.response.userHandle), }, }; } function isSameOrigin(input) { try { return new URL(String(input?.url || input), origin).origin === origin; } catch (_error) { return false; } } function isDashboardApi(input) { try { const url = new URL(String(input?.url || input), origin); return url.origin === origin && url.pathname.startsWith(base + 'api/v1/'); } catch (_error) { return false; } } async function fetchWithDeadline(input, options, method, phase = 'request') { if (!isDashboardApi(input)) return fetchImpl(input, options); const controller = new AbortController(); const callerSignal = options.signal || input?.signal; let timeout; let rejectCancellation; const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; }); const cancelFromCaller = () => { const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError'); error.source = 'caller'; controller.abort(error); rejectCancellation(error); }; if (callerSignal?.aborted) cancelFromCaller(); else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true }); const deadline = new Promise((_resolve, reject) => { timeout = setTimeout(() => { const error = new Error( SAFE_METHODS.has(method) ? 'Request timed out. Try again.' : 'Request timed out. Refresh to verify the outcome before retrying.' ); error.name = 'TimeoutError'; error.method = method; error.outcome = 'unknown'; error.safeToRetry = SAFE_METHODS.has(method); error.phase = phase; controller.abort(error); reject(error); }, requestTimeoutMs); }); try { return await Promise.race([ Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })), deadline, cancellation, ]); } finally { clearTimeout(timeout); callerSignal?.removeEventListener?.('abort', cancelFromCaller); } } async function authorizeWithPasskey(details) { if (!credentials?.get) return null; const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' }); const csrf = csrfToken(); if (csrf) headers.set('X-CSRF-Token', csrf); const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', { method: 'POST', headers, body: JSON.stringify({ action: details.action, target: details.target }), }, 'POST', 'passkey-authorization'); if (!optionsResponse.ok) return null; const publicKey = await optionsResponse.json(); const challenge = publicKey.challenge; publicKey.challenge = decodeBase64Url(publicKey.challenge); publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({ ...item, id: decodeBase64Url(item.id), })); const credential = await credentials.get({ publicKey }); const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', { method: 'POST', headers, body: JSON.stringify({ challenge, credential: authenticationCredentialJSON(credential), action: details.action, target: details.target, }), }, 'POST', 'passkey-authorization'); if (!verified.ok) return null; const payload = await verified.json().catch(() => ({})); return payload.grant || null; } async function sessionFetch(input, options = {}, allowStepUp = true) { const method = String(options.method || input?.method || 'GET').toUpperCase(); const requestOptions = { ...options }; if (!SAFE_METHODS.has(method) && isSameOrigin(input)) { const headers = new Headers(options.headers || input?.headers || {}); const csrf = csrfToken(); if (csrf) headers.set('X-CSRF-Token', csrf); requestOptions.headers = headers; } const response = await fetchWithDeadline(input, requestOptions, method); if (response.status === 428 && isSameOrigin(input) && allowStepUp) { const payload = await response.clone().json().catch(() => ({})); const detail = payload?.detail || {}; if (detail.code === 'step_up_required' && detail.action && detail.target) { let grant = null; try { grant = await authorizeWithPasskey(detail); } catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ } if (!grant) { const accessToken = await promptAuthorization({ action: detail.action, target: detail.target, }); if (!accessToken) return response; const authorizationHeaders = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json', }); const csrf = csrfToken(); if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf); const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', { method: 'POST', headers: authorizationHeaders, body: JSON.stringify({ access_token: accessToken, action: detail.action, target: detail.target, }), }, 'POST', 'fresh-authorization'); if (!authorization.ok) return authorization; const authorizationPayload = await authorization.json().catch(() => ({})); grant = authorizationPayload.grant; } if (!grant) return response; const retryHeaders = new Headers(requestOptions.headers || input?.headers || {}); retryHeaders.set('X-Step-Up-Grant', grant); return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); } } if ( response.status === 401 && isSameOrigin(input) && !expirationStarted && !idleLockStarted ) { expirationStarted = true; const payload = await response.clone().json().catch(() => ({})); if (payload.code === 'session_revoked') { expirationStarted = false; await handleRemoteRevocation(); } else if (payload.code === 'session_idle') { expirationStarted = false; handleSessionIdle(); } else { expirationStarted = false; await handleSessionExpiry(); } } return response; } function recordActivity() { const current = now(); if ( expirationStarted || idleLockStarted || activityHeartbeat || current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs ) return Promise.resolve(false); lastActivityHeartbeatAt = current; activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' }) .then(async response => { if (!response.ok) return false; const payload = await response.json().catch(() => ({})); const expiresAt = storedOfflineLease(); if (expiresAt && Number.isInteger(payload.idle_expires_at)) { await publishOfflineLease(expiresAt, payload.idle_expires_at); } return true; }) .catch(() => false) .finally(() => { activityHeartbeat = null; }); return activityHeartbeat; } function startActivityHeartbeat() { ['pointerdown', 'keydown', 'touchstart'].forEach(type => { addActivityListener?.(type, recordActivity, { passive: true }); }); } function startReconnectResume() { addActivityListener?.('online', async () => { if (await refreshOfflineLease()) await resumeQueuedWork(); }); } async function refreshOfflineLease() { try { const response = await sessionFetch(base + 'api/v1/session'); if (!response.ok) return false; const payload = await response.json().catch(() => ({})); if ( !payload.authenticated || !Number.isInteger(payload.expires_at) || !Number.isInteger(payload.idle_expires_at) ) return false; await publishOfflineLease(payload.expires_at, payload.idle_expires_at); return true; } catch (_error) { const expiresAt = storedOfflineLease(); if (expiresAt * 1000 <= now()) { if (expiresAt) await handleSessionExpiry(); return false; } const idleExpiresAt = storedOfflineIdleLease(); if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) { handleSessionIdle(); return false; } scheduleOfflineExpiry(expiresAt); scheduleOfflineIdle(idleExpiresAt); return true; } } function removeDashboardStorage(storage) { if (!storage) return; const keys = []; try { for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key?.startsWith('stackchain.')) keys.push(key); } keys.forEach(key => storage.removeItem(key)); } catch (_error) { /* Cookie invalidation still protects server data. */ } } async function stopWorkerOutbox() { const registration = await serviceWorker?.ready; if (!registration?.active || !MessageChannel) return; await new Promise((resolve, reject) => { const channel = new MessageChannel(); const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000); channel.port1.onmessage = event => { clearTimeout(timeout); if (event.data?.ok) resolve(); else reject(new Error(event.data?.error || 'Background outbox purge failed.')); }; registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]); }); } function deletePrivateDatabase(name) { if (!indexedDB) return Promise.resolve(); return new Promise((resolve, reject) => { let request; try { request = indexedDB.deleteDatabase(name); } catch (error) { reject(error); return; } request.onsuccess = () => resolve(); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.')); }); } async function clearPrivateDeviceData() { removeDashboardStorage(localStorage); if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); try { await stopWorkerOutbox(); for (const name of privateDatabases) await deletePrivateDatabase(name); } catch (_error) { const error = new Error('Could not clear private queued work from this device.'); onClearError(error); throw error; } try { const keys = await caches?.keys?.() || []; await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))); } catch (_error) { /* A later service-worker activation can clear stale caches. */ } } async function enrollPasskey() { if (!credentials?.create) throw new Error('Passkeys are not supported'); const optionsResponse = await sessionFetch( base + 'api/v1/passkeys/registration/options', { method: 'POST' } ); if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment'); const publicKey = await optionsResponse.json(); const challenge = publicKey.challenge; publicKey.challenge = decodeBase64Url(publicKey.challenge); publicKey.user.id = decodeBase64Url(publicKey.user.id); publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({ ...item, id: decodeBase64Url(item.id), })); const credential = await credentials.create({ publicKey }); const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge, credential: { id: credential.id, type: credential.type, rawId: encodeBase64Url(credential.rawId), response: { attestationObject: encodeBase64Url(credential.response.attestationObject), clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), transports: credential.response.getTransports?.() || [], }, }, }), }); if (!response.ok) throw new Error('Could not verify passkey enrollment'); return true; } async function listActiveDevices() { const response = await sessionFetch(base + 'api/v1/sessions'); if (!response.ok) throw new Error('Could not load active devices'); const payload = await response.json(); return Array.isArray(payload.devices) ? payload.devices : []; } async function listPasskeys() { const response = await sessionFetch(base + 'api/v1/passkeys'); if (!response.ok) throw new Error('Could not load enrolled passkeys'); const payload = await response.json(); return Array.isArray(payload.passkeys) ? payload.passkeys : []; } async function revokePasskey(passkey) { if (!passkey?.management_id) return false; const confirmed = confirmAction?.( `Remove the passkey for ${passkey.device_label}? This cannot be undone.` ); if (!confirmed) return false; const response = await sessionFetch( base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id), { method: 'DELETE' }, ); if (!response.ok) throw new Error('Could not remove enrolled passkey'); return response.json(); } function formatAuthenticationAlert(alert) { const failed = Math.max(0, Number(alert?.failed_count) || 0); const blocked = Math.max(0, Number(alert?.blocked_count) || 0); const method = alert?.method === 'passkey' ? 'passkey' : 'token'; const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}` + (blocked ? ` · ${blocked} blocked` : ''); const formatTime = value => new Date(Number(value) * 1000).toLocaleString( 'en-US', { timeZone: 'UTC' }, ); return { title, detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`, }; } async function listSecurityEvents(cursor = null) { const query = new URLSearchParams({ limit: '25' }); if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor)); const response = await sessionFetch(base + 'api/v1/security-events?' + query); if (!response.ok) throw new Error('Could not load security activity'); const payload = await response.json(); return { events: Array.isArray(payload.events) ? payload.events : [], authentication_alerts: Array.isArray(payload.authentication_alerts) ? payload.authentication_alerts : [], next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null, }; } async function revokeActiveDevice(device) { if (!device?.management_id || device.current) return false; const confirmed = confirmAction?.(`Sign out ${device.device_label}?`); if (!confirmed) return false; const response = await sessionFetch( base + 'api/v1/sessions/' + encodeURIComponent(device.management_id), { method: 'DELETE' }, ); if (!response.ok) throw new Error('Could not revoke active device'); return true; } async function signOut() { try { await sessionFetch(base + 'api/v1/session', { method: 'DELETE' }); } finally { await clearPrivateDeviceData(); location.assign(base + 'login'); } } async function signOutAllDevices() { const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.'); if (!confirmed) return false; const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' }); if (!response.ok) throw new Error('Could not sign out all devices'); await clearPrivateDeviceData(); location.assign(base + 'login'); return true; } async function resumeQueuedWork() { try { const registration = await serviceWorker?.ready; registration?.active?.postMessage({ type: 'stackchain-resume-outbox' }); } catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ } } return { fetch: sessionFetch, signOut, signOutAllDevices, listActiveDevices, listPasskeys, listSecurityEvents, formatAuthenticationAlert, enrollPasskey, revokeActiveDevice, revokePasskey, clearPrivateDeviceData, handleServiceWorkerMessage, refreshOfflineLease, resumeQueuedWork, startReconnectResume, recordActivity, startActivityHeartbeat, }; });