stackchain-dashboard/frontend/session.js
timmy acd2d82d10
All checks were successful
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 4s
fix: resume queued delivery after sign-in (#266)
2026-08-08 05:32:29 +00:00

102 lines
3.9 KiB
JavaScript

(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,
location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
boundary.resumeQueuedWork();
};
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, location,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
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) onExpired();
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 clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
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 signOut() {
try {
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
} finally {
await clearPrivateDeviceData();
location.assign(base + 'login');
}
}
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, clearPrivateDeviceData, resumeQueuedWork };
});