feat: require operator sessions for privileged access (#258)
This commit is contained in:
1 parent
e40c75b360
commit
39357263d0
10 files changed
+655
-25
No files matched your search
@@ -292,6 +292,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<div class="toolbar">
|
||||
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<span class="small muted" id="clock"></span>
|
||||
</div>
|
||||
@@ -747,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v15';
|
||||
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
@@ -31,8 +32,25 @@ const SHELL = [
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(new URL(url, self.location.origin), options);
|
||||
async function sessionCsrf() {
|
||||
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const csrf = await sessionCsrf();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
(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,
|
||||
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());
|
||||
};
|
||||
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, 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');
|
||||
}
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
|
||||
});
|
||||
Reference in new issue
Block a user