From a60f2cfac3aa58827dcd9b3fdc927ededfff6dea Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 11:48:54 +0000 Subject: [PATCH] fix: purge every private offline store (Closes #889) --- frontend/index.html | 1 + frontend/private-data-registry.js | 9 ++++++ frontend/private-device-data.js | 14 ++++++---- frontend/service-worker.js | 12 ++++++-- frontend/session.js | 14 ++++++---- src/views.py | 2 +- tests/test_frontend_bundle.py | 2 +- tests/test_login_frontend.py | 2 ++ tests/test_private_data_registry.py | 43 +++++++++++++++++++++++++++++ tests/test_service_worker.py | 14 ++++++++-- 10 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 frontend/private-data-registry.js create mode 100644 tests/test_private_data_registry.py diff --git a/frontend/index.html b/frontend/index.html index 7c4568b..db24aca 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1243,6 +1243,7 @@ + diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js new file mode 100644 index 0000000..3ca2b84 --- /dev/null +++ b/frontend/private-data-registry.js @@ -0,0 +1,9 @@ +(function (root) { + const databases = Object.freeze([ + 'stackchain-background-outbox-v1', + 'stackchain-offline-work-v2', + 'stackchain-unfiled-captures-v1', + ]); + if (typeof module !== 'undefined' && module.exports) module.exports = databases; + else root.stackchainPrivateDatabases = databases; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/frontend/private-device-data.js b/frontend/private-device-data.js index 55e0d74..8fe4b94 100644 --- a/frontend/private-device-data.js +++ b/frontend/private-device-data.js @@ -1,5 +1,10 @@ (function (root, factory) { - if (typeof module !== 'undefined' && module.exports) module.exports = factory; + if (typeof module !== 'undefined' && module.exports) { + module.exports = options => factory({ + ...options, + privateDatabases: require('./private-data-registry.js'), + }); + } else root.stackchainPrivateDeviceData = factory({ localStorage: root.localStorage, sessionStorage: root.sessionStorage, @@ -7,9 +12,10 @@ caches: root.caches, serviceWorker: root.navigator?.serviceWorker, MessageChannel: root.MessageChannel, + privateDatabases: root.stackchainPrivateDatabases, }); })(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({ - localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, + localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, privateDatabases, }) { function removeOwnedStorage(storage) { if (!storage) return; @@ -52,9 +58,7 @@ removeOwnedStorage(localStorage); if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage); await stopWorkerOutbox(); - await deletePrivateDatabase('stackchain-background-outbox-v1'); - await deletePrivateDatabase('stackchain-offline-work-v2'); - await deletePrivateDatabase('stackchain-unfiled-captures-v1'); + for (const name of privateDatabases) await deletePrivateDatabase(name); const keys = await caches?.keys?.() || []; await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 6cc2a6e..cdb698c 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,10 +1,12 @@ const BASE = new URL('./', self.location.href).pathname; +importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); const CACHE = 'stackchain-dashboard-shell-v102'; 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 PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000; +const PRIVATE_DATABASES = self.stackchainPrivateDatabases; const SHELL = [ BASE, BASE + 'manifest.webmanifest', @@ -210,9 +212,13 @@ async function deletePrivateDatabase(name) { }); } +async function deletePrivateDatabases() { + for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name); +} + async function purgeRevokedSessionData() { await issueSync.purge(); - await deletePrivateDatabase('stackchain-unfiled-captures-v1'); + await deletePrivateDatabases(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) @@ -249,7 +255,7 @@ async function offlineLeaseState(cache) { async function expiredOfflineResponse() { await issueSync.purge(); - await deletePrivateDatabase('stackchain-unfiled-captures-v1'); + await deletePrivateDatabases(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) @@ -370,7 +376,7 @@ self.addEventListener('message', event => { if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { try { await issueSync.purge(); - await deletePrivateDatabase('stackchain-unfiled-captures-v1'); + await deletePrivateDatabases(); event.ports?.[0]?.postMessage({ ok: true }); } catch (error) { event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') }); diff --git a/frontend/session.js b/frontend/session.js index c4b5727..debc694 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -1,5 +1,10 @@ (function (root, factory) { - if (typeof module !== 'undefined' && module.exports) module.exports = 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); @@ -15,6 +20,7 @@ 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), @@ -82,7 +88,7 @@ root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener, promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, @@ -454,9 +460,7 @@ if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); try { await stopWorkerOutbox(); - await deletePrivateDatabase('stackchain-background-outbox-v1'); - await deletePrivateDatabase('stackchain-offline-work-v2'); - await deletePrivateDatabase('stackchain-unfiled-captures-v1'); + 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); diff --git a/src/views.py b/src/views.py index 3a62b00..385e09e 100644 --- a/src/views.py +++ b/src/views.py @@ -17,7 +17,7 @@ LOGIN_HTML = """

Operator sign in

Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.

Recovery

-""" +""" class RevalidatingHTMLResponse(HTMLResponse): diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index c3988a6..f5021a5 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -300,7 +300,7 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules() assert f"BASE + '{build.runtime_name}'" in build.service_worker_source for source in build.page_sources: - if source == "static/background-issue-sync.js": + if f"importScripts(BASE + '{source}')" in build.service_worker_source: continue assert f"BASE + '{source}'" not in build.service_worker_source diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index 209d203..e517ac2 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -100,7 +100,9 @@ const controller = createLoginController({{ async def test_login_loads_private_data_purger_before_controller(): html = await login() + assert '' in html assert '' in html + assert html.index('static/private-data-registry.js') < html.index('static/private-device-data.js') assert html.index('static/private-device-data.js') < html.index('static/login.js') diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py new file mode 100644 index 0000000..c9971de --- /dev/null +++ b/tests/test_private_data_registry.py @@ -0,0 +1,43 @@ +import json +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FRONTEND = ROOT / "frontend" +REGISTRY = FRONTEND / "private-data-registry.js" + + +def test_private_database_registry_covers_every_shipped_indexeddb_store(): + harness = f""" +const databases = require({json.dumps(str(REGISTRY))}); +process.stdout.write(JSON.stringify(databases)); +""" + completed = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + registered = set(json.loads(completed.stdout)) + + opened = set() + for path in FRONTEND.glob("*.js"): + source = path.read_text() + opened.update(re.findall(r"dbName\s*=\s*['\"](stackchain-[^'\"]+)['\"]", source)) + opened.update(re.findall(r"createIndexedDbTransaction\([^\n]+['\"](stackchain-[^'\"]+)['\"]", source)) + + assert registered == opened + assert registered == { + "stackchain-background-outbox-v1", + "stackchain-offline-work-v2", + "stackchain-unfiled-captures-v1", + } + + +def test_every_private_data_purge_context_consumes_the_shared_registry(): + for name in ("private-device-data.js", "session.js", "service-worker.js"): + source = (FRONTEND / name).read_text() + assert "stackchainPrivateDatabases" in source + assert not re.search(r"deletePrivateDatabase\(['\"]stackchain-", source) + + html = (FRONTEND / "index.html").read_text() + assert html.index('static/private-data-registry.js') < html.index('static/session.js') diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 6aa8275..aea188c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -6,6 +6,7 @@ import pytest WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js" +PRIVATE_DATA_REGISTRY = WORKER.parent / "private-data-registry.js" def run_worker_scenario(scenario: str) -> dict: @@ -46,6 +47,7 @@ const context = {{ location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, __STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15, __STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15, + stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}), __STACKCHAIN_SHARED_ATTACHMENT_STORE: {{ put: async (id, value) => {{ state.sharedRecords[id] = value; }}, get: async id => state.sharedRecords[id] || null, @@ -316,7 +318,9 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl assert result["outcome"] == {"status": 401, "code": "session_revoked"} assert result["state"]["outboxPurges"] == 1 assert result["state"]["deletedDatabases"] == [ - "stackchain-unfiled-captures-v1" + "stackchain-background-outbox-v1", + "stackchain-offline-work-v2", + "stackchain-unfiled-captures-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["clientMessages"] == [ @@ -406,7 +410,9 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): assert result["state"]["outboxPurges"] == 1 assert result["state"]["deletedDatabases"] == [ - "stackchain-unfiled-captures-v1" + "stackchain-background-outbox-v1", + "stackchain-offline-work-v2", + "stackchain-unfiled-captures-v1", ] assert result["replies"] == [{"ok": True}] @@ -999,7 +1005,9 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she assert result["body"] == "Your Stackchain session expired. Reconnect and sign in." assert result["state"]["outboxPurges"] == 1 assert result["state"]["deletedDatabases"] == [ - "stackchain-unfiled-captures-v1" + "stackchain-background-outbox-v1", + "stackchain-offline-work-v2", + "stackchain-unfiled-captures-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert "private cached dashboard" not in result["body"]