From 2e2a7821aecd0d828e770a5baa36f59b627ff08f Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 12:03:55 +0000 Subject: [PATCH] feat: expire offline work with sessions (#399) --- frontend/service-worker.js | 45 +++++++++++++- frontend/session.js | 58 +++++++++++++++++- src/main.py | 5 +- tests/test_dashboard_auth.py | 28 +++++++-- tests/test_dashboard_session_frontend.py | 74 +++++++++++++++++++++-- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 55 ++++++++++++++--- tests/test_today_sync.py | 2 +- 11 files changed, 243 insertions(+), 32 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 82a07fc..f986b0f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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' } }, diff --git a/frontend/session.js b/frontend/session.js index 33f587c..167b4fd 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -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, }; }); diff --git a/src/main.py b/src/main.py index df23f34..90e3694 100644 --- a/src/main.py +++ b/src/main.py @@ -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: diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 2b055ec..26d8629 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -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 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index b444b57..e1771b1 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -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( """ diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index fd5e038..9a702af 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 6ec4046..ced53d6 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index ca2ec9d..54f6843 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 10a4de0..d7f7d2e 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index bc33b81..4305f59 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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( """ diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 9672f58..89137d4 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -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