(function (root, factory) { if (typeof module !== 'undefined' && module.exports) module.exports = factory; 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, MessageChannel: root.MessageChannel, 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(); 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 devicesList = root.document.getElementById('active-devices-list'); const devicesStatus = root.document.getElementById('active-devices-status'); const closeDevices = root.document.getElementById('close-active-devices'); const renderDevices = async () => { devicesStatus.textContent = 'Loading active devices…'; devicesList.replaceChildren(); try { const devices = await boundary.listActiveDevices(); devices.forEach(device => { const row = root.document.createElement('article'); row.className = 'active-device'; const details = root.document.createElement('div'); const label = root.document.createElement('strong'); label.textContent = device.device_label; const timing = root.document.createElement('span'); timing.className = 'small muted'; timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`; details.append(label, timing); if (device.current) { const current = root.document.createElement('span'); current.className = 'active-device-current'; current.textContent = 'This device'; details.append(current); } const revoke = root.document.createElement('button'); revoke.type = 'button'; revoke.textContent = device.current ? 'Sign out' : 'Revoke'; revoke.addEventListener('click', async () => { if (device.current) await boundary.signOut(); else if (await boundary.revokeActiveDevice(device)) await renderDevices(); }); row.append(details, revoke); devicesList.append(row); }); devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.'; } catch (_error) { devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; } }; if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => { devicesSheet.hidden = false; closeDevices?.focus(); renderDevices(); }); if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { devicesSheet.hidden = true; devicesButton?.focus(); }); 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, MessageChannel, 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 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) return; expirationStarted = 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 scheduleOfflineExpiry(expiresAt) { const delay = Math.max(0, expiresAt * 1000 - now()); setTimer(() => handleSessionExpiry(), delay); } async function publishOfflineLease(expiresAt) { localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt)); scheduleOfflineExpiry(expiresAt); const registration = await serviceWorker?.ready; registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt }); } 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 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 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) { 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 grant = await authorization.json().catch(() => ({})); if (!grant.grant) return response; const retryHeaders = new Headers(requestOptions.headers || input?.headers || {}); retryHeaders.set('X-Step-Up-Grant', grant.grant); return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); } } if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { 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 || activityHeartbeat || current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs ) return Promise.resolve(false); lastActivityHeartbeatAt = current; activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' }) .then(response => response.ok) .catch(() => false) .finally(() => { activityHeartbeat = null; }); return activityHeartbeat; } function startActivityHeartbeat() { ['pointerdown', 'keydown', 'touchstart'].forEach(type => { addActivityListener?.(type, recordActivity, { passive: true }); }); } 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)) return false; await publishOfflineLease(payload.expires_at); return true; } catch (_error) { const expiresAt = storedOfflineLease(); if (expiresAt * 1000 > now()) { scheduleOfflineExpiry(expiresAt); return true; } if (expiresAt) await handleSessionExpiry(); return false; } } 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 deletePrivateOutbox() { if (!indexedDB) return Promise.resolve(); return new Promise((resolve, reject) => { let request; try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); } 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(); await deletePrivateOutbox(); } 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 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 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, revokeActiveDevice, clearPrivateDeviceData, handleServiceWorkerMessage, refreshOfflineLease, resumeQueuedWork, recordActivity, startActivityHeartbeat, }; });