Expire offline private work with operator sessions #400

Merged
rockachopa merged 1 commits from timmy/399-offline-session-lease into main 2026-08-09 12:06:15 +00:00
11 changed files with 243 additions and 32 deletions

View File

@ -1,6 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v61';
const CACHE = 'stackchain-dashboard-shell-v62';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@ -104,6 +105,41 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
}
async function expiredOfflineResponse() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
return new Response(
'Your Stackchain session expired. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
return cache.match(BASE);
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
@ -177,6 +213,9 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
@ -215,13 +254,13 @@ self.addEventListener('fetch', event => {
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },

View File

@ -81,7 +81,9 @@
devicesSheet.hidden = true;
devicesButton?.focus();
});
boundary.resumeQueuedWork();
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);
@ -94,6 +96,8 @@
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
@ -105,10 +109,38 @@
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');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
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() {
@ -222,13 +254,32 @@
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
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 = [];
@ -338,6 +389,7 @@
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
};
});

View File

@ -875,10 +875,13 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
@app.get("/api/v1/session")
async def session_status(request: Request):
session = request.state.dashboard_session
return {
payload = {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
}
if session is not None:
payload["expires_at"] = session.expires_at
return payload
def _today_store() -> TodayStore:

View File

@ -254,12 +254,16 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
payload = response.json()
assert payload["authenticated"] is True
assert isinstance(payload["expires_at"], int)
assert payload["expires_at"] > int(time.time())
assert response.headers["cache-control"] == "no-store"
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@ -267,14 +271,26 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
)
response = await client.get("/api/v1/session")
payload = response.json()
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/session")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert "expires_at" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch

View File

@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
getItem(key) {{ return this.values.get(key) || null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
@ -27,6 +29,7 @@ const boundary = createSessionBoundary({{
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
if (state.failFetch) throw new Error('offline');
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
@ -53,6 +56,8 @@ const boundary = createSessionBoundary({{
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
@ -227,6 +232,46 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
]
assert result["leaseDelay"] > 0
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '1');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
@ -260,7 +305,7 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
result = run_session_scenario(
"""
state.responseStatus = 401;
@ -269,6 +314,7 @@ await Promise.all([
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
@ -276,9 +322,12 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
assert result["replacedAfterDeletion"] is True
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
@ -314,6 +363,21 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v61" in worker
assert "stackchain-dashboard-shell-v62" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v61" in worker
assert "stackchain-dashboard-shell-v62" in worker

View File

@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/plan-today.js'" in source

View File

@ -14,10 +14,23 @@ const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
return state.cachedBody === null ? null : new Response(state.cachedBody);
}},
put: async (request, response) => {{
const key = String(request.url || request);
state.puts.push(key);
storedResponses.set(key, response.clone());
}},
}};
const context = {{
URL, Request, Response, Headers, AbortController,
@ -108,7 +121,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -117,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -125,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -141,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -408,6 +421,30 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({
status: response.status,
body: await response.text(),
state,
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""

View File

@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/today-sync.js'" in source