693 lines
29 KiB
JavaScript
693 lines
29 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,
|
||
credentials: root.navigator?.credentials,
|
||
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 passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||
const closeDevices = root.document.getElementById('close-active-devices');
|
||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||
const activityList = root.document.getElementById('security-activity-list');
|
||
const activityStatus = root.document.getElementById('security-activity-status');
|
||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||
let activityCursor = null;
|
||
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.';
|
||
}
|
||
};
|
||
const renderPasskeys = async () => {
|
||
if (!passkeysList || !passkeysStatus) return;
|
||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||
passkeysList.replaceChildren();
|
||
try {
|
||
const enrolled = await boundary.listPasskeys();
|
||
enrolled.forEach(passkey => {
|
||
const row = root.document.createElement('article');
|
||
row.className = 'enrolled-passkey';
|
||
const details = root.document.createElement('div');
|
||
const label = root.document.createElement('strong');
|
||
label.textContent = passkey.device_label;
|
||
const timing = root.document.createElement('span');
|
||
timing.className = 'small muted';
|
||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||
details.append(label, timing);
|
||
const remove = root.document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.textContent = 'Remove passkey';
|
||
remove.addEventListener('click', async () => {
|
||
remove.disabled = true;
|
||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||
try {
|
||
const outcome = await boundary.revokePasskey(passkey);
|
||
if (outcome) {
|
||
passkeysStatus.textContent = outcome.current_session
|
||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||
: 'Passkey removed.';
|
||
await renderPasskeys();
|
||
if (outcome.session_revoked) await renderDevices();
|
||
} else {
|
||
remove.disabled = false;
|
||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||
}
|
||
} catch (_error) {
|
||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||
remove.disabled = false;
|
||
}
|
||
});
|
||
row.append(details, remove);
|
||
passkeysList.append(row);
|
||
});
|
||
passkeysStatus.textContent = enrolled.length
|
||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||
: 'No passkeys enrolled.';
|
||
} catch (_error) {
|
||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||
}
|
||
};
|
||
const renderSecurityActivity = async (append = false) => {
|
||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||
loadMoreActivity.hidden = true;
|
||
if (!append) {
|
||
activityCursor = null;
|
||
activityList.replaceChildren();
|
||
}
|
||
try {
|
||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||
const labels = {
|
||
sign_in: 'Signed in',
|
||
sign_out: 'Signed out',
|
||
device_revoked: 'Device access revoked',
|
||
all_sessions_revoked: 'All device access revoked',
|
||
issue_closed: 'Issue closed',
|
||
pull_merged: 'Pull request merged',
|
||
};
|
||
page.events.forEach(event => {
|
||
const row = root.document.createElement('article');
|
||
row.className = 'security-event';
|
||
const title = root.document.createElement('strong');
|
||
title.textContent = labels[event.kind] || 'Security event';
|
||
const details = root.document.createElement('span');
|
||
details.className = 'small muted';
|
||
const context = [
|
||
event.device_label,
|
||
event.method,
|
||
event.target,
|
||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||
].filter(value => typeof value === 'string' && value).join(' · ');
|
||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||
row.append(title, details);
|
||
activityList.append(row);
|
||
});
|
||
activityCursor = page.next_cursor;
|
||
activityStatus.textContent = activityList.children.length
|
||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||
: 'No security activity yet.';
|
||
loadMoreActivity.textContent = 'Load older activity';
|
||
loadMoreActivity.hidden = !activityCursor;
|
||
} catch (_error) {
|
||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||
loadMoreActivity.textContent = 'Retry activity';
|
||
loadMoreActivity.hidden = false;
|
||
}
|
||
};
|
||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||
devicesSheet.hidden = false;
|
||
closeDevices?.focus();
|
||
renderDevices();
|
||
renderPasskeys();
|
||
renderSecurityActivity();
|
||
});
|
||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||
devicesSheet.hidden = true;
|
||
devicesButton?.focus();
|
||
});
|
||
enrollPasskey?.addEventListener('click', async () => {
|
||
enrollPasskey.disabled = true;
|
||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||
try {
|
||
await boundary.enrollPasskey();
|
||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||
await renderPasskeys();
|
||
} catch (_error) {
|
||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||
} finally {
|
||
enrollPasskey.disabled = false;
|
||
}
|
||
});
|
||
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, 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 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) {
|
||
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 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();
|
||
}
|
||
|
||
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 : [],
|
||
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,
|
||
enrollPasskey,
|
||
revokeActiveDevice,
|
||
revokePasskey,
|
||
clearPrivateDeviceData,
|
||
handleServiceWorkerMessage,
|
||
refreshOfflineLease,
|
||
resumeQueuedWork,
|
||
recordActivity,
|
||
startActivityHeartbeat,
|
||
};
|
||
});
|