fix: purge every private offline store (Closes #889)
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-15 11:48:54 +00:00
parent 2eea42859e
commit a60f2cfac3
10 changed files with 95 additions and 18 deletions

View File

@ -1243,6 +1243,7 @@
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/conversation-action-hydrator.js"></script>

View File

@ -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);

View File

@ -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))

View File

@ -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.') });

View File

@ -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);

View File

@ -17,7 +17,7 @@ LOGIN_HTML = """<!doctype html>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>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.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
<script src="static/private-data-registry.js"></script><script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
class RevalidatingHTMLResponse(HTMLResponse):

View File

@ -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

View File

@ -100,7 +100,9 @@ const controller = createLoginController({{
async def test_login_loads_private_data_purger_before_controller():
html = await login()
assert '<script src="static/private-data-registry.js"></script>' in html
assert '<script src="static/private-device-data.js"></script>' 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')

View File

@ -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')

View File

@ -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"]