(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, confirmAction: message => root.confirm(message), 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 = () => { 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.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, onExpired = () => {}, onClearError = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; async function handleRemoteRevocation() { if (expirationStarted) return; expirationStarted = true; await clearPrivateDeviceData(); location.replace(base + 'login?reason=session-revoked'); } async function handleServiceWorkerMessage(event) { if (event?.data?.type === 'stackchain-session-revoked') { await handleRemoteRevocation(); } } 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; } } async function sessionFetch(input, options = {}) { 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 fetchImpl(input, requestOptions); 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 { onExpired(); location.replace(base + 'login?reason=session-expired'); } } return response; } 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, resumeQueuedWork, }; });