632 lines
24 KiB
JavaScript
632 lines
24 KiB
JavaScript
(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 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;
|
||
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).open();
|
||
});
|
||
} finally {
|
||
loadingSecurityCenter = false;
|
||
}
|
||
};
|
||
if (devicesButton && devicesSheet) {
|
||
devicesButton.addEventListener('click', openSecurityCenter);
|
||
}
|
||
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, 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,
|
||
};
|
||
});
|