Purge every private offline store after background session revocation #890
|
|
@ -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>
|
||||
|
|
|
|||
9
frontend/private-data-registry.js
Normal file
9
frontend/private-data-registry.js
Normal 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);
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.') });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
|
|||
43
tests/test_private_data_registry.py
Normal file
43
tests/test_private_data_registry.py
Normal 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')
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user